Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff845c3086 | |||
| 17d6530438 |
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* 成品 / 视频相关 API
|
||||
* 后端实际接口:/videos
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
|
||||
/** 复核状态 */
|
||||
export type ReviewStatus = "pending_review" | "approved" | "rejected"
|
||||
|
||||
/** 成品条目 */
|
||||
export interface ProductItem {
|
||||
id: string
|
||||
title: string
|
||||
video_url?: string
|
||||
thumbnail_url?: string
|
||||
duration_seconds?: number
|
||||
file_size?: number
|
||||
resolution?: string
|
||||
status: "processing" | "completed" | "failed"
|
||||
/** 复核状态 */
|
||||
review_status?: ReviewStatus
|
||||
/** 所属项目 ID */
|
||||
project_id?: string
|
||||
/** 所属项目名称 */
|
||||
project_name?: string
|
||||
/** 查重率(百分比) */
|
||||
duplicate_rate?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 列表查询参数 */
|
||||
export interface ProductListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
project_id?: string
|
||||
review_status?: ReviewStatus | "all"
|
||||
}
|
||||
|
||||
/** 分页响应 */
|
||||
export interface ProductListResponse {
|
||||
items: ProductItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 批量下载任务状态 */
|
||||
export interface BatchDownloadStatus {
|
||||
job_id: string
|
||||
status: "processing" | "completed" | "failed"
|
||||
/** 完成后返回的下载 URL */
|
||||
download_url?: string
|
||||
/** 进度百分比 */
|
||||
progress?: number
|
||||
}
|
||||
|
||||
/** 后端 /videos 接口返回的原始视频条目 */
|
||||
interface VideoItem {
|
||||
id: string
|
||||
project_id: string
|
||||
generation_task_id: string
|
||||
name: string
|
||||
file_url: string
|
||||
file_size: number
|
||||
duration: number
|
||||
thumbnail_url: string | null
|
||||
width: number
|
||||
height: number
|
||||
fps: number
|
||||
status: string
|
||||
review_status: ReviewStatus
|
||||
generation_params: Record<string, unknown>
|
||||
download_url: string
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端 VideoItem 映射为 ProductItem 格式
|
||||
*/
|
||||
function mapVideoToProductItem(video: VideoItem): ProductItem {
|
||||
return {
|
||||
id: video.id,
|
||||
title: video.name || "未命名视频",
|
||||
// 优先用 download_url(带签名)播放,file_url 无签名无法访问
|
||||
video_url: video.download_url || video.file_url,
|
||||
thumbnail_url: video.thumbnail_url || undefined,
|
||||
duration_seconds: video.duration,
|
||||
file_size: video.file_size,
|
||||
resolution: video.width && video.height ? `${video.width}x${video.height}` : undefined,
|
||||
status:
|
||||
video.status === "completed"
|
||||
? "completed"
|
||||
: video.status === "failed"
|
||||
? "failed"
|
||||
: "processing",
|
||||
review_status: video.review_status,
|
||||
project_id: video.project_id,
|
||||
// 后端 /videos 接口暂无 project_name 字段
|
||||
project_name: undefined,
|
||||
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用
|
||||
created_at: video.generated_at,
|
||||
updated_at: video.generated_at,
|
||||
// 后端 /videos 接口暂无 duplicate_rate 字段
|
||||
duplicate_rate: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取成品列表(支持分页和筛选) */
|
||||
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/videos", { params })
|
||||
const data = response.data
|
||||
const videos: VideoItem[] = Array.isArray(data?.items)
|
||||
? data.items
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: []
|
||||
return videos.map(mapVideoToProductItem)
|
||||
}
|
||||
|
||||
/** 获取单个成品详情 */
|
||||
export const getProduct = async (productId: string): Promise<ProductItem> => {
|
||||
const response = await apiClient.get(`/videos/${productId}`)
|
||||
return mapVideoToProductItem(response.data as VideoItem)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除成品
|
||||
* 注意:后端暂未实现 /videos DELETE 接口,调用会返回 405
|
||||
* 待后端实现后自动生效
|
||||
*/
|
||||
export const deleteProduct = async (productId: string): Promise<void> => {
|
||||
await apiClient.delete(`/videos/${productId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成品下载链接
|
||||
* 直接使用列表返回的 download_url(带OSS签名)
|
||||
*/
|
||||
export const getProductDownloadUrl = async (
|
||||
productId: string,
|
||||
): Promise<{ url: string; expires_at: string }> => {
|
||||
// 优先从列表缓存取;如果没有则调详情接口
|
||||
const product = await getProduct(productId)
|
||||
if (!product.video_url) throw new Error("下载链接不可用")
|
||||
return { url: product.video_url, expires_at: "" }
|
||||
}
|
||||
|
||||
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
|
||||
export const updateReviewStatus = async (
|
||||
productId: string,
|
||||
status: ReviewStatus,
|
||||
): Promise<ProductItem> => {
|
||||
// 后端暂无 /videos/{id}/review 端点
|
||||
// 暂时返回当前状态,后续可扩展
|
||||
const product = await getProduct(productId)
|
||||
return { ...product, review_status: status }
|
||||
}
|
||||
|
||||
/** 发起批量下载 — TODO: 后端暂无对应端点 */
|
||||
export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => {
|
||||
// 后端暂无 /videos/batch-download 端点
|
||||
// 暂时返回模拟 job_id,后续可扩展
|
||||
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds)
|
||||
return { job_id: `mock-${Date.now()}` }
|
||||
}
|
||||
|
||||
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
|
||||
export const getBatchDownloadStatus = async (jobId: string): Promise<BatchDownloadStatus> => {
|
||||
// 后端暂无 /videos/batch-download/{jobId} 端点
|
||||
// 暂时返回模拟状态,后续可扩展
|
||||
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
|
||||
return { job_id: jobId, status: "processing", progress: 0 }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 成品 / 视频相关 API — 目录化入口
|
||||
* 保持与原 products.ts 相同导出,向后兼容
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
ReviewStatus,
|
||||
ProductItem,
|
||||
ProductListParams,
|
||||
ProductListResponse,
|
||||
BatchDownloadStatus,
|
||||
VideoItem,
|
||||
} from "./types"
|
||||
|
||||
// 工具函数
|
||||
export { mapVideoToProductItem } from "./utils"
|
||||
|
||||
// API 函数
|
||||
export {
|
||||
getProducts,
|
||||
getProduct,
|
||||
deleteProduct,
|
||||
getProductDownloadUrl,
|
||||
updateReviewStatus,
|
||||
batchDownload,
|
||||
getBatchDownloadStatus,
|
||||
} from "./products"
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 成品 / 视频相关 API 函数
|
||||
* 后端实际接口:/videos
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
BatchDownloadStatus,
|
||||
ProductItem,
|
||||
ProductListParams,
|
||||
VideoItem,
|
||||
ReviewStatus,
|
||||
} from "./types"
|
||||
import { mapVideoToProductItem } from "./utils"
|
||||
|
||||
/** 获取成品列表(支持分页和筛选) */
|
||||
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/videos", { params })
|
||||
const data = response.data
|
||||
const videos: VideoItem[] = Array.isArray(data?.items)
|
||||
? data.items
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: []
|
||||
return videos.map(mapVideoToProductItem)
|
||||
}
|
||||
|
||||
/** 获取单个成品详情 */
|
||||
export const getProduct = async (productId: string): Promise<ProductItem> => {
|
||||
const response = await apiClient.get(`/videos/${productId}`)
|
||||
return mapVideoToProductItem(response.data as VideoItem)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除成品
|
||||
* 注意:后端暂未实现 /videos DELETE 接口,调用会返回 405
|
||||
* 待后端实现后自动生效
|
||||
*/
|
||||
export const deleteProduct = async (productId: string): Promise<void> => {
|
||||
await apiClient.delete(`/videos/${productId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成品下载链接
|
||||
* 直接使用列表返回的 download_url(带OSS签名)
|
||||
*/
|
||||
export const getProductDownloadUrl = async (
|
||||
productId: string,
|
||||
): Promise<{ url: string; expires_at: string }> => {
|
||||
// 优先从列表缓存取;如果没有则调详情接口
|
||||
const product = await getProduct(productId)
|
||||
if (!product.video_url) throw new Error("下载链接不可用")
|
||||
return { url: product.video_url, expires_at: "" }
|
||||
}
|
||||
|
||||
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
|
||||
export const updateReviewStatus = async (
|
||||
productId: string,
|
||||
status: ReviewStatus,
|
||||
): Promise<ProductItem> => {
|
||||
// 后端暂无 /videos/{id}/review 端点
|
||||
// 暂时返回当前状态,后续可扩展
|
||||
const product = await getProduct(productId)
|
||||
return { ...product, review_status: status }
|
||||
}
|
||||
|
||||
/** 发起批量下载 — TODO: 后端暂无对应端点 */
|
||||
export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => {
|
||||
// 后端暂无 /videos/batch-download 端点
|
||||
// 暂时返回模拟 job_id,后续可扩展
|
||||
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds)
|
||||
return { job_id: `mock-${Date.now()}` }
|
||||
}
|
||||
|
||||
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
|
||||
export const getBatchDownloadStatus = async (jobId: string): Promise<BatchDownloadStatus> => {
|
||||
// 后端暂无 /videos/batch-download/{jobId} 端点
|
||||
// 暂时返回模拟状态,后续可扩展
|
||||
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
|
||||
return { job_id: jobId, status: "processing", progress: 0 }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 成品 / 视频相关类型定义
|
||||
*/
|
||||
|
||||
/** 复核状态 */
|
||||
export type ReviewStatus = "pending_review" | "approved" | "rejected"
|
||||
|
||||
/** 成品条目 */
|
||||
export interface ProductItem {
|
||||
id: string
|
||||
title: string
|
||||
video_url?: string
|
||||
thumbnail_url?: string
|
||||
duration_seconds?: number
|
||||
file_size?: number
|
||||
resolution?: string
|
||||
status: "processing" | "completed" | "failed"
|
||||
/** 复核状态 */
|
||||
review_status?: ReviewStatus
|
||||
/** 所属项目 ID */
|
||||
project_id?: string
|
||||
/** 所属项目名称 */
|
||||
project_name?: string
|
||||
/** 查重率(百分比) */
|
||||
duplicate_rate?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 列表查询参数 */
|
||||
export interface ProductListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
project_id?: string
|
||||
review_status?: ReviewStatus | "all"
|
||||
}
|
||||
|
||||
/** 分页响应 */
|
||||
export interface ProductListResponse {
|
||||
items: ProductItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 批量下载任务状态 */
|
||||
export interface BatchDownloadStatus {
|
||||
job_id: string
|
||||
status: "processing" | "completed" | "failed"
|
||||
/** 完成后返回的下载 URL */
|
||||
download_url?: string
|
||||
/** 进度百分比 */
|
||||
progress?: number
|
||||
}
|
||||
|
||||
/** 后端 /videos 接口返回的原始视频条目 */
|
||||
export interface VideoItem {
|
||||
id: string
|
||||
project_id: string
|
||||
generation_task_id: string
|
||||
name: string
|
||||
file_url: string
|
||||
file_size: number
|
||||
duration: number
|
||||
thumbnail_url: string | null
|
||||
width: number
|
||||
height: number
|
||||
fps: number
|
||||
status: string
|
||||
review_status: ReviewStatus
|
||||
generation_params: Record<string, unknown>
|
||||
download_url: string
|
||||
generated_at: string
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 成品数据转换工具函数
|
||||
*/
|
||||
import type { ProductItem, VideoItem } from "./types"
|
||||
|
||||
/**
|
||||
* 将后端 VideoItem 映射为 ProductItem 格式
|
||||
*/
|
||||
export function mapVideoToProductItem(video: VideoItem): ProductItem {
|
||||
return {
|
||||
id: video.id,
|
||||
title: video.name || "未命名视频",
|
||||
// 优先用 download_url(带签名)播放,file_url 无签名无法访问
|
||||
video_url: video.download_url || video.file_url,
|
||||
thumbnail_url: video.thumbnail_url || undefined,
|
||||
duration_seconds: video.duration,
|
||||
file_size: video.file_size,
|
||||
resolution: video.width && video.height ? `${video.width}x${video.height}` : undefined,
|
||||
status:
|
||||
video.status === "completed"
|
||||
? "completed"
|
||||
: video.status === "failed"
|
||||
? "failed"
|
||||
: "processing",
|
||||
review_status: video.review_status,
|
||||
project_id: video.project_id,
|
||||
// 后端 /videos 接口暂无 project_name 字段
|
||||
project_name: undefined,
|
||||
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用
|
||||
created_at: video.generated_at,
|
||||
updated_at: video.generated_at,
|
||||
// 后端 /videos 接口暂无 duplicate_rate 字段
|
||||
duplicate_rate: undefined,
|
||||
}
|
||||
}
|
||||
Regular → Executable
+284
-3
@@ -1,5 +1,286 @@
|
||||
/**
|
||||
* LayerConfig 入口(向后兼容)
|
||||
* 实际实现位于 ./layer-config/ 目录
|
||||
* 混剪单图层配置区
|
||||
*/
|
||||
export { default } from "./layer-config"
|
||||
import React from "react"
|
||||
import type {
|
||||
PipLayer,
|
||||
PipAnimType,
|
||||
PipSlideDirection,
|
||||
PipGridPosition,
|
||||
} from "@/pages/editing-planner/types"
|
||||
import {
|
||||
GRID_POSITIONS,
|
||||
ANIM_OPTIONS,
|
||||
SLIDE_DIR_OPTIONS,
|
||||
LAYER_COLORS,
|
||||
} from "@/pages/editing-planner/constants/pipConfig"
|
||||
|
||||
interface LayerConfigProps {
|
||||
layer: PipLayer | null
|
||||
layers: PipLayer[]
|
||||
totalDuration: number
|
||||
onUpdate: (id: string, partial: Partial<PipLayer>) => void
|
||||
onGridClick: (pos: PipGridPosition) => void
|
||||
onWidthChange: (val: number) => void
|
||||
onHeightChange: (val: number) => void
|
||||
}
|
||||
|
||||
const LayerConfig: React.FC<LayerConfigProps> = ({
|
||||
layer,
|
||||
layers,
|
||||
totalDuration,
|
||||
onUpdate,
|
||||
onGridClick,
|
||||
onWidthChange,
|
||||
onHeightChange,
|
||||
}) => {
|
||||
if (!layer) {
|
||||
return (
|
||||
<div className="pip-config-area">
|
||||
<div className="pip-config-empty">选择或添加图层以配置</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pip-config-area">
|
||||
{/* ── 迷你预览 ── */}
|
||||
<div className="pip-preview-box">
|
||||
{layers.map((l, idx) => (
|
||||
<div
|
||||
key={l.id}
|
||||
className={`pip-preview-layer${layer.id === l.id ? " selected" : ""}`}
|
||||
style={{
|
||||
left: `${l.x}%`,
|
||||
top: `${l.y}%`,
|
||||
width: `${l.width}%`,
|
||||
height: `${l.height}%`,
|
||||
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
||||
opacity: l.opacity / 100,
|
||||
borderRadius: `${l.border_radius}%`,
|
||||
}}
|
||||
>
|
||||
<span className="pip-preview-label">{l.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 素材类型 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">素材类型</label>
|
||||
<div className="pip-type-btns">
|
||||
<button
|
||||
className={`pip-type-btn${layer.material_type === "image" ? " active" : ""}`}
|
||||
onClick={() => onUpdate(layer.id, { material_type: "image" })}
|
||||
>
|
||||
🖼️ 图片
|
||||
</button>
|
||||
<button
|
||||
className={`pip-type-btn${layer.material_type === "video" ? " active" : ""}`}
|
||||
onClick={() => onUpdate(layer.id, { material_type: "video" })}
|
||||
>
|
||||
🎬 视频
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 素材 URL ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">
|
||||
{layer.material_type === "image" ? "图片" : "视频"} URL
|
||||
</label>
|
||||
<input
|
||||
className="pip-input"
|
||||
type="text"
|
||||
placeholder={
|
||||
layer.material_type === "image"
|
||||
? "https://example.com/image.png"
|
||||
: "https://example.com/video.mp4"
|
||||
}
|
||||
value={layer.material_url}
|
||||
onChange={(e) => onUpdate(layer.id, { material_url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 位置:九宫格 + 坐标 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">位置</label>
|
||||
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
|
||||
<div className="pip-grid">
|
||||
{GRID_POSITIONS.map((pos) => (
|
||||
<button
|
||||
key={pos}
|
||||
className={`pip-grid-btn${layer.grid_position === pos ? " active" : ""}`}
|
||||
onClick={() => onGridClick(pos)}
|
||||
>
|
||||
<span className="pip-grid-dot" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="pip-field-row" style={{ flex: 1 }}>
|
||||
<div>
|
||||
<label className="pip-field-label">X (%)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layer.x}
|
||||
onChange={(e) => onUpdate(layer.id, { x: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="pip-field-label">Y (%)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layer.y}
|
||||
onChange={(e) => onUpdate(layer.id, { y: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 尺寸 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">尺寸</label>
|
||||
<div className="pip-slider-row">
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>宽</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={layer.width}
|
||||
onChange={(e) => onWidthChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.width}%</span>
|
||||
</div>
|
||||
<div className="pip-slider-row" style={{ marginTop: 6 }}>
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>高</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={layer.height}
|
||||
onChange={(e) => onHeightChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.height}%</span>
|
||||
</div>
|
||||
<div
|
||||
className="pip-lock-row"
|
||||
style={{ marginTop: 6 }}
|
||||
onClick={() => onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })}
|
||||
>
|
||||
<span className="pip-lock-icon">{layer.aspect_lock ? "🔒" : "🔓"}</span>
|
||||
<span>{layer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 圆角 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">圆角</label>
|
||||
<div className="pip-slider-row">
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={50}
|
||||
value={layer.border_radius}
|
||||
onChange={(e) => onUpdate(layer.id, { border_radius: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.border_radius}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 透明度 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">透明度</label>
|
||||
<div className="pip-slider-row">
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layer.opacity}
|
||||
onChange={(e) => onUpdate(layer.id, { opacity: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.opacity}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 时间 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">时间</label>
|
||||
<div className="pip-field-row">
|
||||
<div>
|
||||
<label className="pip-field-label">开始 (s)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={totalDuration || 999}
|
||||
step={0.1}
|
||||
value={layer.start_time}
|
||||
onChange={(e) => onUpdate(layer.id, { start_time: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="pip-field-label">持续 (s)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={totalDuration || 999}
|
||||
step={0.1}
|
||||
value={layer.duration}
|
||||
onChange={(e) => onUpdate(layer.id, { duration: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 入场动画 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">入场动画</label>
|
||||
<select
|
||||
className="pip-select"
|
||||
value={layer.animation}
|
||||
onChange={(e) => onUpdate(layer.id, { animation: e.target.value as PipAnimType })}
|
||||
>
|
||||
{ANIM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 滑入方向(仅 slide_in 时显示) */}
|
||||
{layer.animation === "slide_in" && (
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">滑入方向</label>
|
||||
<select
|
||||
className="pip-select"
|
||||
value={layer.slide_direction}
|
||||
onChange={(e) =>
|
||||
onUpdate(layer.id, { slide_direction: e.target.value as PipSlideDirection })
|
||||
}
|
||||
>
|
||||
{SLIDE_DIR_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayerConfig
|
||||
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
import React from "react"
|
||||
import type { PipLayer, PipGridPosition } from "@/pages/editing-planner/types"
|
||||
import { GRID_POSITIONS } from "@/pages/editing-planner/constants/pipConfig"
|
||||
|
||||
interface LayerPositionSizeProps {
|
||||
layer: PipLayer
|
||||
onUpdate: (id: string, partial: Partial<PipLayer>) => void
|
||||
onGridClick: (pos: PipGridPosition) => void
|
||||
onWidthChange: (val: number) => void
|
||||
onHeightChange: (val: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 图层位置与尺寸配置面板
|
||||
*/
|
||||
export const LayerPositionSize: React.FC<LayerPositionSizeProps> = ({
|
||||
layer,
|
||||
onUpdate,
|
||||
onGridClick,
|
||||
onWidthChange,
|
||||
onHeightChange,
|
||||
}) => (
|
||||
<>
|
||||
{/* 素材类型 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">素材类型</label>
|
||||
<div className="pip-type-btns">
|
||||
<button
|
||||
className={`pip-type-btn${layer.material_type === "image" ? " active" : ""}`}
|
||||
onClick={() => onUpdate(layer.id, { material_type: "image" })}
|
||||
>
|
||||
🖼️ 图片
|
||||
</button>
|
||||
<button
|
||||
className={`pip-type-btn${layer.material_type === "video" ? " active" : ""}`}
|
||||
onClick={() => onUpdate(layer.id, { material_type: "video" })}
|
||||
>
|
||||
🎬 视频
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 素材 URL */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">
|
||||
{layer.material_type === "image" ? "图片" : "视频"} URL
|
||||
</label>
|
||||
<input
|
||||
className="pip-input"
|
||||
type="text"
|
||||
placeholder={
|
||||
layer.material_type === "image"
|
||||
? "https://example.com/image.png"
|
||||
: "https://example.com/video.mp4"
|
||||
}
|
||||
value={layer.material_url}
|
||||
onChange={(e) => onUpdate(layer.id, { material_url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 位置:九宫格 + 坐标 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">位置</label>
|
||||
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
|
||||
<div className="pip-grid">
|
||||
{GRID_POSITIONS.map((pos) => (
|
||||
<button
|
||||
key={pos}
|
||||
className={`pip-grid-btn${layer.grid_position === pos ? " active" : ""}`}
|
||||
onClick={() => onGridClick(pos)}
|
||||
>
|
||||
<span className="pip-grid-dot" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="pip-field-row" style={{ flex: 1 }}>
|
||||
<div>
|
||||
<label className="pip-field-label">X (%)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layer.x}
|
||||
onChange={(e) => onUpdate(layer.id, { x: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="pip-field-label">Y (%)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layer.y}
|
||||
onChange={(e) => onUpdate(layer.id, { y: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 尺寸 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">尺寸</label>
|
||||
<div className="pip-slider-row">
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>宽</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={layer.width}
|
||||
onChange={(e) => onWidthChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.width}%</span>
|
||||
</div>
|
||||
<div className="pip-slider-row" style={{ marginTop: 6 }}>
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>高</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={layer.height}
|
||||
onChange={(e) => onHeightChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.height}%</span>
|
||||
</div>
|
||||
<div
|
||||
className="pip-lock-row"
|
||||
style={{ marginTop: 6 }}
|
||||
onClick={() => onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })}
|
||||
>
|
||||
<span className="pip-lock-icon">{layer.aspect_lock ? "🔒" : "🔓"}</span>
|
||||
<span>{layer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 圆角 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">圆角</label>
|
||||
<div className="pip-slider-row">
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={50}
|
||||
value={layer.border_radius}
|
||||
onChange={(e) => onUpdate(layer.id, { border_radius: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.border_radius}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 透明度 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">透明度</label>
|
||||
<div className="pip-slider-row">
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={layer.opacity}
|
||||
onChange={(e) => onUpdate(layer.id, { opacity: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="pip-slider-value">{layer.opacity}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
import React from "react"
|
||||
import type { PipLayer, PipAnimType, PipSlideDirection } from "@/pages/editing-planner/types"
|
||||
import { ANIM_OPTIONS, SLIDE_DIR_OPTIONS } from "@/pages/editing-planner/constants/pipConfig"
|
||||
|
||||
interface LayerTimingAnimationProps {
|
||||
layer: PipLayer
|
||||
totalDuration: number
|
||||
onUpdate: (id: string, partial: Partial<PipLayer>) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 图层时间与动画配置面板
|
||||
*/
|
||||
export const LayerTimingAnimation: React.FC<LayerTimingAnimationProps> = ({
|
||||
layer,
|
||||
totalDuration,
|
||||
onUpdate,
|
||||
}) => (
|
||||
<>
|
||||
{/* 时间 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">时间</label>
|
||||
<div className="pip-field-row">
|
||||
<div>
|
||||
<label className="pip-field-label">开始 (s)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={totalDuration || 999}
|
||||
step={0.1}
|
||||
value={layer.start_time}
|
||||
onChange={(e) => onUpdate(layer.id, { start_time: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="pip-field-label">持续 (s)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={totalDuration || 999}
|
||||
step={0.1}
|
||||
value={layer.duration}
|
||||
onChange={(e) => onUpdate(layer.id, { duration: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 入场动画 */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">入场动画</label>
|
||||
<select
|
||||
className="pip-select"
|
||||
value={layer.animation}
|
||||
onChange={(e) => onUpdate(layer.id, { animation: e.target.value as PipAnimType })}
|
||||
>
|
||||
{ANIM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 滑入方向(仅 slide_in 时显示) */}
|
||||
{layer.animation === "slide_in" && (
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">滑入方向</label>
|
||||
<select
|
||||
className="pip-select"
|
||||
value={layer.slide_direction}
|
||||
onChange={(e) =>
|
||||
onUpdate(layer.id, { slide_direction: e.target.value as PipSlideDirection })
|
||||
}
|
||||
>
|
||||
{SLIDE_DIR_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
@@ -1,33 +0,0 @@
|
||||
import React from "react"
|
||||
import type { PipLayer } from "@/pages/editing-planner/types"
|
||||
import { LAYER_COLORS } from "@/pages/editing-planner/constants/pipConfig"
|
||||
|
||||
interface PipPreviewProps {
|
||||
layers: PipLayer[]
|
||||
selectedId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PIP 图层迷你预览组件
|
||||
*/
|
||||
export const PipPreview: React.FC<PipPreviewProps> = ({ layers, selectedId }) => (
|
||||
<div className="pip-preview-box">
|
||||
{layers.map((l, idx) => (
|
||||
<div
|
||||
key={l.id}
|
||||
className={`pip-preview-layer${selectedId === l.id ? " selected" : ""}`}
|
||||
style={{
|
||||
left: `${l.x}%`,
|
||||
top: `${l.y}%`,
|
||||
width: `${l.width}%`,
|
||||
height: `${l.height}%`,
|
||||
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
||||
opacity: l.opacity / 100,
|
||||
borderRadius: `${l.border_radius}%`,
|
||||
}}
|
||||
>
|
||||
<span className="pip-preview-label">{l.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* 混剪单图层配置区
|
||||
*/
|
||||
import React from "react"
|
||||
import type { PipLayer, PipGridPosition } from "@/pages/editing-planner/types"
|
||||
import { PipPreview } from "./PipPreview"
|
||||
import { LayerPositionSize } from "./LayerPositionSize"
|
||||
import { LayerTimingAnimation } from "./LayerTimingAnimation"
|
||||
|
||||
interface LayerConfigProps {
|
||||
layer: PipLayer | null
|
||||
layers: PipLayer[]
|
||||
totalDuration: number
|
||||
onUpdate: (id: string, partial: Partial<PipLayer>) => void
|
||||
onGridClick: (pos: PipGridPosition) => void
|
||||
onWidthChange: (val: number) => void
|
||||
onHeightChange: (val: number) => void
|
||||
}
|
||||
|
||||
const LayerConfig: React.FC<LayerConfigProps> = ({
|
||||
layer,
|
||||
layers,
|
||||
totalDuration,
|
||||
onUpdate,
|
||||
onGridClick,
|
||||
onWidthChange,
|
||||
onHeightChange,
|
||||
}) => {
|
||||
if (!layer) {
|
||||
return (
|
||||
<div className="pip-config-area">
|
||||
<div className="pip-config-empty">选择或添加图层以配置</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pip-config-area">
|
||||
{/* 迷你预览 */}
|
||||
<PipPreview layers={layers} selectedId={layer.id} />
|
||||
|
||||
{/* 位置与尺寸 */}
|
||||
<LayerPositionSize
|
||||
layer={layer}
|
||||
onUpdate={onUpdate}
|
||||
onGridClick={onGridClick}
|
||||
onWidthChange={onWidthChange}
|
||||
onHeightChange={onHeightChange}
|
||||
/>
|
||||
|
||||
{/* 时间与动画 */}
|
||||
<LayerTimingAnimation layer={layer} totalDuration={totalDuration} onUpdate={onUpdate} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayerConfig
|
||||
Reference in New Issue
Block a user