From 870ea10351a22cdbf2ed2b55c64b255b635b1007 Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Mon, 15 Dec 2025 23:33:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8A=9F=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/commands/openspec/apply.md | 23 + .claude/commands/openspec/archive.md | 27 + .claude/commands/openspec/proposal.md | 28 + .claude/settings.local.json | 3 +- AGENTS.md | 18 + CLAUDE.md | 19 + frontend/app/web-gold/package.json | 1 + frontend/app/web-gold/src/api/material.js | 52 +- .../web-gold/src/components/SidebarNav.vue | 3 +- frontend/app/web-gold/src/router/index.js | 1 + .../src/views/material/MaterialList.vue | 3 +- .../app/web-gold/src/views/material/Mix.vue | 785 +++++++++++++ frontend/package.json | 2 +- openspec/AGENTS.md | 456 ++++++++ openspec/changes/add-ice-916-crop/design.md | 77 ++ openspec/changes/add-ice-916-crop/proposal.md | 21 + .../add-ice-916-crop/specs/mix-task/spec.md | 48 + openspec/changes/add-ice-916-crop/tasks.md | 18 + openspec/mix-logic-spec.md | 1026 +++++++++++++++++ openspec/professional-simple-mix-spec.md | 135 +++ openspec/project.md | 31 + .../controller/AppTikUserFileController.java | 6 +- .../file/dal/dataobject/TikUserFileDO.java | 4 + .../tik/file/service/TikUserFileService.java | 3 +- .../file/service/TikUserFileServiceImpl.java | 9 +- .../tik/file/vo/app/AppTikUserFileRespVO.java | 3 + .../vo/app/AppTikUserFileUploadReqVO.java | 3 + .../tik/media/BatchProduceAlignment.java | 191 +++ .../tik/mix/constants/MixTaskConstants.java | 4 +- .../tik/mix/dal/dataobject/MixTaskDO.java | 20 + .../tik/mix/service/MixTaskServiceImpl.java | 93 +- .../module/tik/mix/util/MixTaskUtils.java | 65 +- .../module/tik/mix/vo/MixTaskSaveReqVO.java | 33 +- .../tik/voice/config/CosyVoiceProperties.java | 4 +- .../voice/config/LatentsyncProperties.java | 4 +- .../tik/media/BatchProduceAlignmentTest.java | 110 ++ 36 files changed, 3289 insertions(+), 40 deletions(-) create mode 100644 .claude/commands/openspec/apply.md create mode 100644 .claude/commands/openspec/archive.md create mode 100644 .claude/commands/openspec/proposal.md create mode 100644 AGENTS.md create mode 100644 frontend/app/web-gold/src/views/material/Mix.vue create mode 100644 openspec/AGENTS.md create mode 100644 openspec/changes/add-ice-916-crop/design.md create mode 100644 openspec/changes/add-ice-916-crop/proposal.md create mode 100644 openspec/changes/add-ice-916-crop/specs/mix-task/spec.md create mode 100644 openspec/changes/add-ice-916-crop/tasks.md create mode 100644 openspec/mix-logic-spec.md create mode 100644 openspec/professional-simple-mix-spec.md create mode 100644 openspec/project.md create mode 100644 yudao-module-tik/src/test/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignmentTest.java diff --git a/.claude/commands/openspec/apply.md b/.claude/commands/openspec/apply.md new file mode 100644 index 0000000000..a36fd964ef --- /dev/null +++ b/.claude/commands/openspec/apply.md @@ -0,0 +1,23 @@ +--- +name: OpenSpec: Apply +description: Implement an approved OpenSpec change and keep tasks in sync. +category: OpenSpec +tags: [openspec, apply] +--- + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. + +**Steps** +Track these steps as TODOs and complete them one by one. +1. Read `changes//proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria. +2. Work through tasks sequentially, keeping edits minimal and focused on the requested change. +3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished. +4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality. +5. Reference `openspec list` or `openspec show ` when additional context is required. + +**Reference** +- Use `openspec show --json --deltas-only` if you need additional context from the proposal while implementing. + diff --git a/.claude/commands/openspec/archive.md b/.claude/commands/openspec/archive.md new file mode 100644 index 0000000000..dbc769586f --- /dev/null +++ b/.claude/commands/openspec/archive.md @@ -0,0 +1,27 @@ +--- +name: OpenSpec: Archive +description: Archive a deployed OpenSpec change and update specs. +category: OpenSpec +tags: [openspec, archive] +--- + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. + +**Steps** +1. Determine the change ID to archive: + - If this prompt already includes a specific change ID (for example inside a `` block populated by slash-command arguments), use that value after trimming whitespace. + - If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends. + - Otherwise, review the conversation, run `openspec list`, and ask the user which change to archive; wait for a confirmed change ID before proceeding. + - If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet. +2. Validate the change ID by running `openspec list` (or `openspec show `) and stop if the change is missing, already archived, or otherwise not ready to archive. +3. Run `openspec archive --yes` so the CLI moves the change and applies spec updates without prompts (use `--skip-specs` only for tooling-only work). +4. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`. +5. Validate with `openspec validate --strict` and inspect with `openspec show ` if anything looks off. + +**Reference** +- Use `openspec list` to confirm change IDs before archiving. +- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off. + diff --git a/.claude/commands/openspec/proposal.md b/.claude/commands/openspec/proposal.md new file mode 100644 index 0000000000..cbb75ce24c --- /dev/null +++ b/.claude/commands/openspec/proposal.md @@ -0,0 +1,28 @@ +--- +name: OpenSpec: Proposal +description: Scaffold a new OpenSpec change and validate strictly. +category: OpenSpec +tags: [openspec, change] +--- + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. +- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files. +- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval. + +**Steps** +1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification. +2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes//`. +3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing. +4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs. +5. Draft spec deltas in `changes//specs//spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant. +6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work. +7. Validate with `openspec validate --strict` and resolve every issue before sharing the proposal. + +**Reference** +- Use `openspec show --json --deltas-only` or `openspec show --type spec` to inspect details when validation fails. +- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones. +- Explore the codebase with `rg `, `ls`, or direct file reads so proposals align with current implementation realities. + diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 49db19d0d6..3e8a6bec5f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -17,7 +17,8 @@ "Bash(ls:*)", "Bash(mysql:*)", "Bash(npm run lint:*)", - "Bash(npx vue-tsc:*)" + "Bash(npx vue-tsc:*)", + "Bash(pnpm add:*)" ], "deny": [], "ask": [] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..0669699416 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ + +# OpenSpec Instructions + +These instructions are for AI assistants working in this project. + +Always open `@/openspec/AGENTS.md` when the request: +- Mentions planning or proposals (words like proposal, spec, change, plan) +- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work +- Sounds ambiguous and you need the authoritative spec before coding + +Use `@/openspec/AGENTS.md` to learn: +- How to create and apply change proposals +- Spec format and conventions +- Project structure and guidelines + +Keep this managed block so 'openspec update' can refresh the instructions. + + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 4fae13ef1d..9a68746f3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,22 @@ + +# OpenSpec Instructions + +These instructions are for AI assistants working in this project. + +Always open `@/openspec/AGENTS.md` when the request: +- Mentions planning or proposals (words like proposal, spec, change, plan) +- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work +- Sounds ambiguous and you need the authoritative spec before coding + +Use `@/openspec/AGENTS.md` to learn: +- How to create and apply change proposals +- Spec format and conventions +- Project structure and guidelines + +Keep this managed block so 'openspec update' can refresh the instructions. + + + # CLAUDE.md 本文档为 Claude Code (claude.ai/code) 在此仓库中处理代码提供指导。请始终用中文沟通 diff --git a/frontend/app/web-gold/package.json b/frontend/app/web-gold/package.json index 525f08a530..8472b31164 100644 --- a/frontend/app/web-gold/package.json +++ b/frontend/app/web-gold/package.json @@ -43,6 +43,7 @@ "eslint-plugin-oxlint": "~1.11.0", "eslint-plugin-vue": "~10.4.0", "globals": "^16.3.0", + "less": "^4.4.2", "normalize.css": "^8.0.1", "npm-run-all2": "^8.0.4", "oxlint": "~1.11.0", diff --git a/frontend/app/web-gold/src/api/material.js b/frontend/app/web-gold/src/api/material.js index 4478c3ef0c..10912194dc 100644 --- a/frontend/app/web-gold/src/api/material.js +++ b/frontend/app/web-gold/src/api/material.js @@ -9,6 +9,39 @@ import { API_BASE } from '@gold/config/api' // 使用 webApi 前缀,确保能够被代理 const BASE_URL = `${API_BASE.APP_TIK}/file` +/** + * 获取视频时长(秒) + * @param {File} file - 视频文件对象 + * @returns {Promise} 时长(秒) + */ +function getVideoDuration(file) { + return new Promise((resolve, reject) => { + // 只处理视频文件 + if (!file.type.startsWith('video/')) { + resolve(null); + return; + } + + const video = document.createElement('video'); + video.preload = 'metadata'; + video.muted = true; // 静音,避免浏览器阻止自动播放 + + video.onloadedmetadata = function() { + const duration = Math.round(video.duration); + URL.revokeObjectURL(video.src); + resolve(duration); + }; + + video.onerror = function() { + URL.revokeObjectURL(video.src); + console.warn('[视频时长] 获取失败,使用默认值60秒'); + resolve(60); // 返回默认值 + }; + + video.src = URL.createObjectURL(file); + }); +} + /** * 素材库 API 服务 */ @@ -34,20 +67,33 @@ export const MaterialService = { * @param {File} file - 文件对象 * @param {string} fileCategory - 文件分类(video/generate/audio/mix/voice) * @param {string} coverBase64 - 视频封面 base64(可选,data URI 格式) + * @param {number} duration - 视频时长(秒,可选,自动获取) * @returns {Promise} */ - uploadFile(file, fileCategory, coverBase64 = null) { + async uploadFile(file, fileCategory, coverBase64 = null, duration = null) { + // 如果没有提供时长且是视频文件,自动获取 + if (duration === null && file.type.startsWith('video/')) { + duration = await getVideoDuration(file); + console.log('[上传] 获取到视频时长:', duration, '秒'); + } + const formData = new FormData() formData.append('file', file) formData.append('fileCategory', fileCategory) - + + // 添加时长(如果是视频文件) + if (duration !== null) { + formData.append('duration', duration.toString()); + console.log('[上传] 附加视频时长:', duration, '秒'); + } + // 如果有封面 base64,添加到表单数据 if (coverBase64) { // base64 格式:data:image/jpeg;base64,/9j/4AAQ... // 后端会解析这个格式 formData.append('coverBase64', coverBase64) } - + // 大文件上传需要更长的超时时间(30分钟) return http.post(`${BASE_URL}/upload`, formData, { timeout: 30 * 60 * 1000 // 30分钟 diff --git a/frontend/app/web-gold/src/components/SidebarNav.vue b/frontend/app/web-gold/src/components/SidebarNav.vue index 475c5e9564..3ea24f84f9 100644 --- a/frontend/app/web-gold/src/components/SidebarNav.vue +++ b/frontend/app/web-gold/src/components/SidebarNav.vue @@ -44,7 +44,8 @@ const items = computed(() => { title: '素材库', children: [ { path: '/material/list', label: '素材列表', icon: 'grid' }, - { path: '/material/mix-task', label: '混剪任务', icon: 'scissors' }, + { path: '/material/mix', label: '智能混剪', icon: 'scissors' }, + { path: '/material/mix-task', label: '混剪任务', icon: 'video' }, { path: '/material/group', label: '素材分组', icon: 'folder' }, ] }, diff --git a/frontend/app/web-gold/src/router/index.js b/frontend/app/web-gold/src/router/index.js index 4dc8803645..27a7cd4452 100644 --- a/frontend/app/web-gold/src/router/index.js +++ b/frontend/app/web-gold/src/router/index.js @@ -55,6 +55,7 @@ const routes = [ children: [ { path: '', redirect: '/material/list' }, { path: 'list', name: '素材列表', component: () => import('../views/material/MaterialList.vue') }, + { path: 'mix', name: '智能混剪', component: () => import('../views/material/Mix.vue') }, { path: 'mix-task', name: '混剪任务', component: () => import('../views/material/MixTaskList.vue') }, { path: 'group', name: '素材分组', component: () => import('../views/material/MaterialGroup.vue') }, ] diff --git a/frontend/app/web-gold/src/views/material/MaterialList.vue b/frontend/app/web-gold/src/views/material/MaterialList.vue index 159c25699f..a0f488b1b2 100644 --- a/frontend/app/web-gold/src/views/material/MaterialList.vue +++ b/frontend/app/web-gold/src/views/material/MaterialList.vue @@ -20,8 +20,7 @@ 素材混剪 diff --git a/frontend/app/web-gold/src/views/material/Mix.vue b/frontend/app/web-gold/src/views/material/Mix.vue new file mode 100644 index 0000000000..63fc467e01 --- /dev/null +++ b/frontend/app/web-gold/src/views/material/Mix.vue @@ -0,0 +1,785 @@ + + + + + diff --git a/frontend/package.json b/frontend/package.json index 7540e34327..b6fd42cb15 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,9 +11,9 @@ "license": "ISC", "description": "", "dependencies": { + "axios": "^1.12.2", "localforage": "^1.10.0", "unocss": "^66.5.4", - "axios": "^1.12.2", "web-storage-cache": "^1.1.1" } } diff --git a/openspec/AGENTS.md b/openspec/AGENTS.md new file mode 100644 index 0000000000..96ab0bb390 --- /dev/null +++ b/openspec/AGENTS.md @@ -0,0 +1,456 @@ +# OpenSpec Instructions + +Instructions for AI coding assistants using OpenSpec for spec-driven development. + +## TL;DR Quick Checklist + +- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search) +- Decide scope: new capability vs modify existing capability +- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`) +- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability +- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement +- Validate: `openspec validate [change-id] --strict` and fix issues +- Request approval: Do not start implementation until proposal is approved + +## Three-Stage Workflow + +### Stage 1: Creating Changes +Create proposal when you need to: +- Add features or functionality +- Make breaking changes (API, schema) +- Change architecture or patterns +- Optimize performance (changes behavior) +- Update security patterns + +Triggers (examples): +- "Help me create a change proposal" +- "Help me plan a change" +- "Help me create a proposal" +- "I want to create a spec proposal" +- "I want to create a spec" + +Loose matching guidance: +- Contains one of: `proposal`, `change`, `spec` +- With one of: `create`, `plan`, `make`, `start`, `help` + +Skip proposal for: +- Bug fixes (restore intended behavior) +- Typos, formatting, comments +- Dependency updates (non-breaking) +- Configuration changes +- Tests for existing behavior + +**Workflow** +1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context. +2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes//`. +3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement. +4. Run `openspec validate --strict` and resolve any issues before sharing the proposal. + +### Stage 2: Implementing Changes +Track these steps as TODOs and complete them one by one. +1. **Read proposal.md** - Understand what's being built +2. **Read design.md** (if exists) - Review technical decisions +3. **Read tasks.md** - Get implementation checklist +4. **Implement tasks sequentially** - Complete in order +5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses +6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality +7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved + +### Stage 3: Archiving Changes +After deployment, create separate PR to: +- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/` +- Update `specs/` if capabilities changed +- Use `openspec archive --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly) +- Run `openspec validate --strict` to confirm the archived change passes checks + +## Before Any Task + +**Context Checklist:** +- [ ] Read relevant specs in `specs/[capability]/spec.md` +- [ ] Check pending changes in `changes/` for conflicts +- [ ] Read `openspec/project.md` for conventions +- [ ] Run `openspec list` to see active changes +- [ ] Run `openspec list --specs` to see existing capabilities + +**Before Creating Specs:** +- Always check if capability already exists +- Prefer modifying existing specs over creating duplicates +- Use `openspec show [spec]` to review current state +- If request is ambiguous, ask 1–2 clarifying questions before scaffolding + +### Search Guidance +- Enumerate specs: `openspec spec list --long` (or `--json` for scripts) +- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available) +- Show details: + - Spec: `openspec show --type spec` (use `--json` for filters) + - Change: `openspec show --json --deltas-only` +- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs` + +## Quick Start + +### CLI Commands + +```bash +# Essential commands +openspec list # List active changes +openspec list --specs # List specifications +openspec show [item] # Display change or spec +openspec validate [item] # Validate changes or specs +openspec archive [--yes|-y] # Archive after deployment (add --yes for non-interactive runs) + +# Project management +openspec init [path] # Initialize OpenSpec +openspec update [path] # Update instruction files + +# Interactive mode +openspec show # Prompts for selection +openspec validate # Bulk validation mode + +# Debugging +openspec show [change] --json --deltas-only +openspec validate [change] --strict +``` + +### Command Flags + +- `--json` - Machine-readable output +- `--type change|spec` - Disambiguate items +- `--strict` - Comprehensive validation +- `--no-interactive` - Disable prompts +- `--skip-specs` - Archive without spec updates +- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive) + +## Directory Structure + +``` +openspec/ +├── project.md # Project conventions +├── specs/ # Current truth - what IS built +│ └── [capability]/ # Single focused capability +│ ├── spec.md # Requirements and scenarios +│ └── design.md # Technical patterns +├── changes/ # Proposals - what SHOULD change +│ ├── [change-name]/ +│ │ ├── proposal.md # Why, what, impact +│ │ ├── tasks.md # Implementation checklist +│ │ ├── design.md # Technical decisions (optional; see criteria) +│ │ └── specs/ # Delta changes +│ │ └── [capability]/ +│ │ └── spec.md # ADDED/MODIFIED/REMOVED +│ └── archive/ # Completed changes +``` + +## Creating Change Proposals + +### Decision Tree + +``` +New request? +├─ Bug fix restoring spec behavior? → Fix directly +├─ Typo/format/comment? → Fix directly +├─ New feature/capability? → Create proposal +├─ Breaking change? → Create proposal +├─ Architecture change? → Create proposal +└─ Unclear? → Create proposal (safer) +``` + +### Proposal Structure + +1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique) + +2. **Write proposal.md:** +```markdown +# Change: [Brief description of change] + +## Why +[1-2 sentences on problem/opportunity] + +## What Changes +- [Bullet list of changes] +- [Mark breaking changes with **BREAKING**] + +## Impact +- Affected specs: [list capabilities] +- Affected code: [key files/systems] +``` + +3. **Create spec deltas:** `specs/[capability]/spec.md` +```markdown +## ADDED Requirements +### Requirement: New Feature +The system SHALL provide... + +#### Scenario: Success case +- **WHEN** user performs action +- **THEN** expected result + +## MODIFIED Requirements +### Requirement: Existing Feature +[Complete modified requirement] + +## REMOVED Requirements +### Requirement: Old Feature +**Reason**: [Why removing] +**Migration**: [How to handle] +``` +If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs//spec.md`—one per capability. + +4. **Create tasks.md:** +```markdown +## 1. Implementation +- [ ] 1.1 Create database schema +- [ ] 1.2 Implement API endpoint +- [ ] 1.3 Add frontend component +- [ ] 1.4 Write tests +``` + +5. **Create design.md when needed:** +Create `design.md` if any of the following apply; otherwise omit it: +- Cross-cutting change (multiple services/modules) or a new architectural pattern +- New external dependency or significant data model changes +- Security, performance, or migration complexity +- Ambiguity that benefits from technical decisions before coding + +Minimal `design.md` skeleton: +```markdown +## Context +[Background, constraints, stakeholders] + +## Goals / Non-Goals +- Goals: [...] +- Non-Goals: [...] + +## Decisions +- Decision: [What and why] +- Alternatives considered: [Options + rationale] + +## Risks / Trade-offs +- [Risk] → Mitigation + +## Migration Plan +[Steps, rollback] + +## Open Questions +- [...] +``` + +## Spec File Format + +### Critical: Scenario Formatting + +**CORRECT** (use #### headers): +```markdown +#### Scenario: User login success +- **WHEN** valid credentials provided +- **THEN** return JWT token +``` + +**WRONG** (don't use bullets or bold): +```markdown +- **Scenario: User login** ❌ +**Scenario**: User login ❌ +### Scenario: User login ❌ +``` + +Every requirement MUST have at least one scenario. + +### Requirement Wording +- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative) + +### Delta Operations + +- `## ADDED Requirements` - New capabilities +- `## MODIFIED Requirements` - Changed behavior +- `## REMOVED Requirements` - Deprecated features +- `## RENAMED Requirements` - Name changes + +Headers matched with `trim(header)` - whitespace ignored. + +#### When to use ADDED vs MODIFIED +- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement. +- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details. +- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name. + +Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead. + +Authoring a MODIFIED requirement correctly: +1) Locate the existing requirement in `openspec/specs//spec.md`. +2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios). +3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior. +4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`. + +Example for RENAMED: +```markdown +## RENAMED Requirements +- FROM: `### Requirement: Login` +- TO: `### Requirement: User Authentication` +``` + +## Troubleshooting + +### Common Errors + +**"Change must have at least one delta"** +- Check `changes/[name]/specs/` exists with .md files +- Verify files have operation prefixes (## ADDED Requirements) + +**"Requirement must have at least one scenario"** +- Check scenarios use `#### Scenario:` format (4 hashtags) +- Don't use bullet points or bold for scenario headers + +**Silent scenario parsing failures** +- Exact format required: `#### Scenario: Name` +- Debug with: `openspec show [change] --json --deltas-only` + +### Validation Tips + +```bash +# Always use strict mode for comprehensive checks +openspec validate [change] --strict + +# Debug delta parsing +openspec show [change] --json | jq '.deltas' + +# Check specific requirement +openspec show [spec] --json -r 1 +``` + +## Happy Path Script + +```bash +# 1) Explore current state +openspec spec list --long +openspec list +# Optional full-text search: +# rg -n "Requirement:|Scenario:" openspec/specs +# rg -n "^#|Requirement:" openspec/changes + +# 2) Choose change id and scaffold +CHANGE=add-two-factor-auth +mkdir -p openspec/changes/$CHANGE/{specs/auth} +printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md +printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md + +# 3) Add deltas (example) +cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF' +## ADDED Requirements +### Requirement: Two-Factor Authentication +Users MUST provide a second factor during login. + +#### Scenario: OTP required +- **WHEN** valid credentials are provided +- **THEN** an OTP challenge is required +EOF + +# 4) Validate +openspec validate $CHANGE --strict +``` + +## Multi-Capability Example + +``` +openspec/changes/add-2fa-notify/ +├── proposal.md +├── tasks.md +└── specs/ + ├── auth/ + │ └── spec.md # ADDED: Two-Factor Authentication + └── notifications/ + └── spec.md # ADDED: OTP email notification +``` + +auth/spec.md +```markdown +## ADDED Requirements +### Requirement: Two-Factor Authentication +... +``` + +notifications/spec.md +```markdown +## ADDED Requirements +### Requirement: OTP Email Notification +... +``` + +## Best Practices + +### Simplicity First +- Default to <100 lines of new code +- Single-file implementations until proven insufficient +- Avoid frameworks without clear justification +- Choose boring, proven patterns + +### Complexity Triggers +Only add complexity with: +- Performance data showing current solution too slow +- Concrete scale requirements (>1000 users, >100MB data) +- Multiple proven use cases requiring abstraction + +### Clear References +- Use `file.ts:42` format for code locations +- Reference specs as `specs/auth/spec.md` +- Link related changes and PRs + +### Capability Naming +- Use verb-noun: `user-auth`, `payment-capture` +- Single purpose per capability +- 10-minute understandability rule +- Split if description needs "AND" + +### Change ID Naming +- Use kebab-case, short and descriptive: `add-two-factor-auth` +- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-` +- Ensure uniqueness; if taken, append `-2`, `-3`, etc. + +## Tool Selection Guide + +| Task | Tool | Why | +|------|------|-----| +| Find files by pattern | Glob | Fast pattern matching | +| Search code content | Grep | Optimized regex search | +| Read specific files | Read | Direct file access | +| Explore unknown scope | Task | Multi-step investigation | + +## Error Recovery + +### Change Conflicts +1. Run `openspec list` to see active changes +2. Check for overlapping specs +3. Coordinate with change owners +4. Consider combining proposals + +### Validation Failures +1. Run with `--strict` flag +2. Check JSON output for details +3. Verify spec file format +4. Ensure scenarios properly formatted + +### Missing Context +1. Read project.md first +2. Check related specs +3. Review recent archives +4. Ask for clarification + +## Quick Reference + +### Stage Indicators +- `changes/` - Proposed, not yet built +- `specs/` - Built and deployed +- `archive/` - Completed changes + +### File Purposes +- `proposal.md` - Why and what +- `tasks.md` - Implementation steps +- `design.md` - Technical decisions +- `spec.md` - Requirements and behavior + +### CLI Essentials +```bash +openspec list # What's in progress? +openspec show [item] # View details +openspec validate --strict # Is it correct? +openspec archive [--yes|-y] # Mark complete (add --yes for automation) +``` + +Remember: Specs are truth. Changes are proposals. Keep them in sync. diff --git a/openspec/changes/add-ice-916-crop/design.md b/openspec/changes/add-ice-916-crop/design.md new file mode 100644 index 0000000000..bb4fe7390a --- /dev/null +++ b/openspec/changes/add-ice-916-crop/design.md @@ -0,0 +1,77 @@ +## Context + +混剪功能需要将多种比例的素材统一输出为 9:16 竖屏视频(720x1280)。 +阿里云 ICE 支持视频裁剪和缩放,需要在 Timeline 中配置正确的参数。 + +## Goals / Non-Goals + +**Goals:** +- 支持横屏 (16:9) 素材自动裁剪为竖屏 (9:16) +- 支持多种裁剪模式(居中、智能、填充) +- 保持视频质量,避免过度拉伸 + +**Non-Goals:** +- 不实现自定义裁剪区域选择 +- 不实现实时预览 + +## Decisions + +### 裁剪模式设计 + +| 模式 | 说明 | 适用场景 | +|------|------|----------| +| `center` | 居中裁剪,保持原始比例 | 主体在画面中央 | +| `smart` | 智能裁剪(ICE AI 识别主体) | 人物/产品展示 | +| `fill` | 填充黑边,不裁剪 | 保留完整画面 | + +### ICE 参数方案 + +**方案 A:使用 CropX/CropY/CropW/CropH** +```json +{ + "MediaURL": "xxx", + "CropX": 280, + "CropY": 0, + "CropW": 720, + "CropH": 1280 +} +``` + +**方案 B:使用 Effects + Crop** +```json +{ + "Effects": [{ + "Type": "Crop", + "X": 280, + "Y": 0, + "Width": 720, + "Height": 1280 + }] +} +``` + +### 裁剪计算公式 + +对于 16:9 横屏素材 (1920x1080) 裁剪为 9:16: +``` +目标比例 = 9/16 = 0.5625 +源比例 = 16/9 = 1.778 + +// 居中裁剪 +cropHeight = sourceHeight = 1080 +cropWidth = cropHeight * (9/16) = 607.5 ≈ 608 +cropX = (sourceWidth - cropWidth) / 2 = (1920 - 608) / 2 = 656 +cropY = 0 +``` + +## Risks / Trade-offs + +- **画面损失**:居中裁剪会丢失左右两侧内容 +- **缩放失真**:填充模式会缩小画面 +- **ICE 兼容性**:需确认 ICE 版本支持的参数 + +## Open Questions + +1. ICE 是否支持智能主体识别裁剪? +2. 是否需要前端预览裁剪效果? +3. 默认裁剪模式选择哪种? diff --git a/openspec/changes/add-ice-916-crop/proposal.md b/openspec/changes/add-ice-916-crop/proposal.md new file mode 100644 index 0000000000..75c783a964 --- /dev/null +++ b/openspec/changes/add-ice-916-crop/proposal.md @@ -0,0 +1,21 @@ +# Change: ICE 增加 9:16 竖屏裁剪支持 + +## Why + +当前混剪功能输出固定为 720x1280 (9:16) 尺寸,但输入素材可能是横屏 (16:9) 或其他比例。 +需要支持自动裁剪/缩放,确保输出视频符合竖屏要求,避免黑边或变形。 + +## What Changes + +- 新增视频裁剪模式配置(居中裁剪 / 智能裁剪 / 填充黑边) +- ICE Timeline 增加 CropMode 参数 +- 后端支持不同比例素材的自动处理 +- 前端可选裁剪模式(默认居中裁剪) + +## Impact + +- Affected specs: `mix-task` +- Affected code: + - `BatchProduceAlignment.java` - Timeline 构建逻辑 + - `MixTaskSaveReqVO.java` - 新增 cropMode 参数 + - `Mix.vue` - 可选裁剪模式 diff --git a/openspec/changes/add-ice-916-crop/specs/mix-task/spec.md b/openspec/changes/add-ice-916-crop/specs/mix-task/spec.md new file mode 100644 index 0000000000..27aaf0c613 --- /dev/null +++ b/openspec/changes/add-ice-916-crop/specs/mix-task/spec.md @@ -0,0 +1,48 @@ +## ADDED Requirements + +### Requirement: 9:16 竖屏裁剪支持 + +混剪系统 SHALL 支持将不同比例的素材自动处理为 9:16 竖屏输出。 + +系统 SHALL 提供以下裁剪模式: +- `center`:居中裁剪,保持原始比例,裁剪超出部分 +- `smart`:智能裁剪,识别主体位置进行裁剪(依赖 ICE 能力) +- `fill`:填充模式,缩放素材并填充黑边保留完整画面 + +系统 SHALL 默认使用 `center` 居中裁剪模式。 + +#### Scenario: 横屏素材居中裁剪 +- **WHEN** 用户上传 16:9 横屏素材(1920x1080) +- **AND** 选择 `center` 裁剪模式 +- **THEN** 系统自动计算裁剪区域(居中取 608x1080) +- **AND** 输出 720x1280 竖屏视频 + +#### Scenario: 竖屏素材无需裁剪 +- **WHEN** 用户上传 9:16 竖屏素材(720x1280) +- **THEN** 系统直接使用原素材 +- **AND** 不进行裁剪处理 + +#### Scenario: 填充模式保留完整画面 +- **WHEN** 用户上传 16:9 横屏素材 +- **AND** 选择 `fill` 填充模式 +- **THEN** 系统缩放素材至竖屏宽度 +- **AND** 上下填充黑边 +- **AND** 输出 720x1280 竖屏视频 + +### Requirement: 裁剪模式配置 + +混剪任务创建 API SHALL 接受可选的 `cropMode` 参数。 + +参数规格: +- 字段名:`cropMode` +- 类型:String +- 可选值:`center` | `smart` | `fill` +- 默认值:`center` + +#### Scenario: 指定裁剪模式 +- **WHEN** 用户创建混剪任务时指定 `cropMode: "fill"` +- **THEN** 所有素材使用填充模式处理 + +#### Scenario: 使用默认裁剪模式 +- **WHEN** 用户创建混剪任务未指定 `cropMode` +- **THEN** 系统使用默认的 `center` 居中裁剪模式 diff --git a/openspec/changes/add-ice-916-crop/tasks.md b/openspec/changes/add-ice-916-crop/tasks.md new file mode 100644 index 0000000000..fab8345baa --- /dev/null +++ b/openspec/changes/add-ice-916-crop/tasks.md @@ -0,0 +1,18 @@ +## 1. 调研阶段 +- [ ] 1.1 确认阿里云 ICE 支持的裁剪参数(CropX/CropY/CropW/CropH 或 ScaleMode) +- [ ] 1.2 测试横屏素材在 ICE 中的默认处理方式 + +## 2. 后端实现 +- [ ] 2.1 MixTaskSaveReqVO 新增 cropMode 字段(center/smart/fill) +- [ ] 2.2 BatchProduceAlignment 实现裁剪计算逻辑 +- [ ] 2.3 ICE Timeline 增加裁剪参数 +- [ ] 2.4 单元测试 + +## 3. 前端实现 +- [ ] 3.1 Mix.vue 新增裁剪模式选择(默认居中裁剪) +- [ ] 3.2 提交参数增加 cropMode + +## 4. 测试验证 +- [ ] 4.1 横屏素材混剪测试 +- [ ] 4.2 竖屏素材混剪测试 +- [ ] 4.3 混合比例素材测试 diff --git a/openspec/mix-logic-spec.md b/openspec/mix-logic-spec.md new file mode 100644 index 0000000000..5d1670c83e --- /dev/null +++ b/openspec/mix-logic-spec.md @@ -0,0 +1,1026 @@ +# 混剪逻辑与页面实现规格(基于阿里云ICE) + +## 📋 核心需求 + +**输入**:用户选择素材分组中的视频 +**输出**:2-3个**不同内容**的混剪视频 +**规则**: +- 每个素材可设置截取时长:**3s-15s**(默认3s) +- 总时长限制:**15s-60s** +- 素材处理:**从每条视频截取指定时长片段**,非简单拼接 +- **视频差异性**:生成的2-3个视频必须内容不同,每个视频使用不同的素材组合 +**底层引擎**:阿里云 ICE (Intelligent Collaboration Editor) + +--- + +## 🎯 核心交互逻辑 + +### 多视频生成机制 + +#### 素材分配算法 + +**核心原则**: +1. 确保每个生成的视频内容不同 +2. **每个视频内素材时长随机截取**(3s-15s范围内) +3. **最后一个视频补全所有素材**,确保无遗漏 + +**算法一:分组循环分配(推荐)** +``` +场景:用户选择 9 个素材,生成 3 个视频 + +分配逻辑: +├─ 视频1:使用素材 [1, 2, 3] (随机时长:5s, 8s, 4s) +├─ 视频2:使用素材 [4, 5, 6] (随机时长:6s, 3s, 9s) +└─ 视频3:使用素材 [7, 8, 9, 1, 2, 3, 4, 5, 6] (补全所有剩余素材,随机时长) + +结果: +- 视频1:3个素材,总时长约17s +- 视频2:3个素材,总时长约18s +- 视频3:9个素材,随机分配时长,总时长约27s +``` + +**算法二:交错分配** +``` +场景:用户选择 12 个素材,生成 2 个视频 + +分配逻辑: +├─ 视频1:使用素材 [1, 3, 5, 7, 9, 11] (奇数位,随机时长) +└─ 视频2:使用素材 [2, 4, 6, 8, 10, 12, 1, 2, 3, 4, 5, 6] (偶数位 + 补全奇数位) + +结果: +- 视频1:6个素材,随机时长,总时长约18s +- 视频2:12个素材(补全),随机时长,总时长约36s +``` + +**算法三:智能平衡分配** +``` +场景:用户选择 10 个素材,生成 3 个视频 + +分配逻辑: +├─ 视频1:素材 [1, 2, 3, 4] (4个素材,随机时长) +├─ 视频2:素材 [5, 6, 7, 8] (4个素材,随机时长) +└─ 视频3:素材 [9, 10, 1, 2, 3, 4, 5, 6, 7, 8] (2个 + 补全前8个) + +结果: +- 视频1、2:各4个素材,随机时长 +- 视频3:10个素材(补全所有),随机时长 +``` + +#### 随机时长截取 + +**规则**: +- 每个素材的时长在3s-15s范围内**随机生成** +- 随机种子基于素材ID和视频序号,确保可重现性 +- 最后一个视频的素材时长也随机生成 + +**示例**: +``` +素材1(ID=123): +- 视频1中使用:随机生成 7s +- 视频2中使用:随机生成 4s +- 视频3中使用:随机生成 12s + +素材2(ID=456): +- 视频1中使用:随机生成 9s +- 视频2中使用:随机生成 6s +- 视频3中使用:随机生成 3s +``` + +#### 最后一个视频补全逻辑 + +**补全规则**: +- 最后一个视频(N号视频)必须包含**所有素材** +- 前面(N-1)个视频使用部分素材,避免重复 +- 补全时保持素材的随机时长特性 + +**实现流程**: +``` +1. 计算每个视频应分配的基础素材数 + baseCount = 素材总数 ÷ 生成数量 + +2. 前(N-1)个视频各分配 baseCount 个素材 + +3. 最后一个视频分配: + - 剩余所有素材(素材总数 - baseCount × (N-1)) + - + 循环补全前面(N-1)个视频使用过的素材 + - 确保包含所有素材 +``` + +#### 容错机制设计 + +**1. 视频时长不足容错** +``` +当素材时长不足时: +├─ 自动循环使用素材补充时长 +├─ 调整素材截取起始点(从素材中间截取) +└─ 确保最终时长达到目标时长 +``` + +**2. ICE API调用容错** +``` +调用失败处理: +├─ 重试机制:最多重试3次,间隔时间递增(1s, 3s, 9s) +├─ 部分成功:如果部分视频成功,返回成功的视频 +└─ 失败补偿:失败的视频可手动重试 +``` + +**3. 随机时长生成容错** +``` +随机生成失败时: +├─ 使用默认时长(5s)作为fallback +├─ 记录异常日志用于调试 +└─ 继续后续流程 +``` + +**4. 素材不足容错** +``` +素材数量不足时: +├─ 循环使用素材填充所有视频 +├─ 调整素材时长(延长或缩短) +└─ 保证每个视频至少包含2个素材 +``` + +**5. 任务状态容错** +``` +多视频状态跟踪: +├─ 部分成功:至少1个视频成功即算任务部分成功 +├─ 状态同步:定期同步所有视频状态 +└─ 失败补偿:提供"补全失败视频"功能 +``` + +#### 目标时长分配 + +**每个视频的目标时长** = 总时长 ÷ 生成数量 + +**示例**: +- 总时长:45s +- 生成数量:3个 +- 目标时长:15s/视频 + +--- + +## 🏗️ 系统架构 + +``` +┌─────────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ 前端页面 │ │ 后端API │ │ 阿里云ICE │ +│ │ │ │ │ │ +│ /material/mix │◄──►│ MixTaskController│◄──►│ IceClient │ +│ (独立混剪页面) │ │ MixTaskService │ │ │ +│ │ │ │ │ Timeline构建 │ +│ 素材时长选择 │ │ 时长校验 │ │ 片段截取 │ +│ 总时长计算 │ │ 参数转换 │ │ 视频合成 │ +└─────────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +--- + +## 📄 前端页面设计 + +### 页面布局 + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ 📹 智能混剪 [返回素材列表] │ +├───────────────────────────────┬────────────────────────────────────────┤ +│ │ │ +│ ┌─ 混剪参数 ─────────────┐ │ ┌─ 素材预览 ────────────────────┐ │ +│ │ │ │ │ │ │ +│ │ 选择素材分组: │ │ │ ┌────┐ ┌────┐ ┌────┐ │ │ +│ │ [下拉选择分组 ▼] │ │ │ │ 01 │ │ 02 │ │ 03 │ ... │ │ +│ │ │ │ │ │封面│ │封面│ │封面│ │ │ +│ │ 视频标题: │ │ │ │ │ │ │ │ │ │ │ +│ │ [输入标题____________] │ │ │ │3s▼ │ │5s▼ │ │3s▼ │ │ │ +│ │ │ │ │ └────┘ └────┘ └────┘ │ │ +│ │ 生成数量: │ │ │ │ │ +│ │ ○ 1个 ○ 2个 ● 3个 │ │ │ 已选择 5 个素材 │ │ +│ │ │ │ │ │ │ +│ │ ──────────────────────│ │ └──────────────────────────────┘ │ +│ │ │ │ │ +│ │ 📊 时长统计 │ │ ┌─ 时长概览 ────────────────────┐ │ +│ │ ├─ 已选素材: 5 个 │ │ │ │ │ +│ │ ├─ 总时长: 18s │ │ │ ████████████░░░░░░ 18s/60s │ │ +│ │ └─ 限制: 15s-60s ✅ │ │ │ ✅ 时长合规 │ │ +│ │ │ │ │ │ │ +│ │ [🚀 开始混剪] │ │ └──────────────────────────────┘ │ +│ │ │ │ │ +│ └────────────────────────┘ │ │ +│ │ │ +└───────────────────────────────┴────────────────────────────────────────┘ +``` + +### 核心组件实现 + +```vue + + + + +``` + +--- + +## 📡 后端API接口变更 + +### 创建混剪任务(新格式) + +```http +POST /api/mix/create +Content-Type: application/json + +{ + "title": "美食纪录片", + "materials": [ + { "fileId": 123, "fileUrl": "https://xxx.com/video1.mp4", "duration": 3 }, + { "fileId": 456, "fileUrl": "https://xxx.com/video2.mp4", "duration": 5 }, + { "fileId": 789, "fileUrl": "https://xxx.com/video3.mp4", "duration": 8 }, + { "fileId": 101, "fileUrl": "https://xxx.com/video4.mp4", "duration": 4 }, + { "fileId": 102, "fileUrl": "https://xxx.com/video5.mp4", "duration": 6 } + ], + "produceCount": 2 +} +``` + +**返回**:`{"code": 0, "data": 12345}` (任务ID) + +**后端处理**: +- 解析materials列表和produceCount +- 使用分配算法将素材分配给每个视频 +- 生成2个不同的视频,每个视频使用不同的素材组合 + +### MixTaskSaveReqVO(新格式) + +```java +@Data +public class MixTaskSaveReqVO { + + @NotBlank(message = "视频标题不能为空") + private String title; + + @NotEmpty(message = "素材列表不能为空") + @Valid + private List materials; + + @NotNull(message = "生成数量不能为空") + @Min(1) @Max(3) + private Integer produceCount; + + @Data + public static class MaterialItem { + private Long fileId; // 素材文件ID + private String fileUrl; // 素材URL + @Min(3) @Max(15) + private Integer duration; // 截取时长(秒): 3-15 + } +} +``` + +### 素材分配算法实现 + +```java +/** + * 素材分配算法 + * 根据素材数量和生成数量,智能分配素材到每个视频 + * 特性: + * 1. 随机时长截取(3s-15s) + * 2. 最后一个视频补全所有素材 + */ +public class MaterialDistribution { + + /** + * 分配素材到多个视频(分组循环分配) + * + * @param materials 原始素材列表 + * @param videoCount 生成视频数量 + * @return 每个视频的素材列表(包含随机时长) + */ + public static List> distribute(List materials, int videoCount) { + List> result = new ArrayList<>(); + + int materialCount = materials.size(); + int baseCount = materialCount / videoCount; + + // 前(N-1)个视频:分配基础素材 + Set usedMaterialIds = new HashSet<>(); + for (int i = 0; i < videoCount - 1; i++) { + List videoMaterials = new ArrayList<>(); + for (int j = 0; j < baseCount; j++) { + int index = i * baseCount + j; + if (index < materialCount) { + MaterialItem material = materials.get(index); + usedMaterialIds.add(material.getFileId()); + // 生成随机时长 + material.setDuration(generateRandomDuration(material.getFileId(), i)); + videoMaterials.add(material); + } + } + result.add(videoMaterials); + } + + // 最后一个视频:补全所有素材 + List lastVideoMaterials = new ArrayList<>(); + // 添加未使用的素材 + for (MaterialItem material : materials) { + if (!usedMaterialIds.contains(material.getFileId())) { + material.setDuration(generateRandomDuration(material.getFileId(), videoCount - 1)); + lastVideoMaterials.add(material); + } + } + // 循环补全已使用的素材(随机选择部分) + List recycledMaterials = new ArrayList<>(); + for (MaterialItem material : materials) { + if (usedMaterialIds.contains(material.getFileId())) { + // 随机决定是否循环使用该素材 + if (Math.random() > 0.3) { // 70%概率循环使用 + MaterialItem recycled = new MaterialItem(); + recycled.setFileId(material.getFileId()); + recycled.setFileUrl(material.getFileUrl()); + recycled.setDuration(generateRandomDuration(material.getFileId(), videoCount - 1)); + recycledMaterials.add(recycled); + } + } + } + lastVideoMaterials.addAll(recycledMaterials); + result.add(lastVideoMaterials); + + return result; + } + + /** + * 生成随机时长(3s-15s) + * 使用素材ID和视频序号作为随机种子,确保可重现性 + */ + private static int generateRandomDuration(Long materialId, int videoIndex) { + Random random = new Random(materialId * 1000L + videoIndex); + return random.nextInt(13) + 3; // 3-15 inclusive + } + + /** + * 交错分配算法(奇偶分配)+ 补全逻辑 + */ + public static List> distributeByParity(List materials, int videoCount) { + List> result = new ArrayList<>(); + + // 创建视频列表 + for (int i = 0; i < videoCount; i++) { + result.add(new ArrayList<>()); + } + + // 按奇偶分配素材 + Set usedMaterialIds = new HashSet<>(); + for (int i = 0; i < materials.size(); i++) { + int videoIndex = i % videoCount; + MaterialItem material = materials.get(i); + material.setDuration(generateRandomDuration(material.getFileId(), videoIndex)); + result.get(videoIndex).add(material); + usedMaterialIds.add(material.getFileId()); + } + + // 最后一个视频补全所有素材 + List lastVideo = result.get(videoCount - 1); + for (MaterialItem material : materials) { + if (!usedMaterialIds.contains(material.getFileId())) { + MaterialItem supplement = new MaterialItem(); + supplement.setFileId(material.getFileId()); + supplement.setFileUrl(material.getFileUrl()); + supplement.setDuration(generateRandomDuration(material.getFileId(), videoCount - 1)); + lastVideo.add(supplement); + } + } + + return result; + } +} +``` + +--- + +## 💾 数据模型变更 + +### MixTaskDO(数据库表) + +```java +@TableName("tik_mix_task") +public class MixTaskDO extends TenantBaseDO { + private Long id; + private Long userId; + private String title; + + // 素材配置(JSON格式存储) + // 格式: [{"fileId":123,"fileUrl":"...","duration":3}, ...] + private String materialsJson; + + private Integer produceCount; + private String jobIds; + private String outputUrls; + private String status; + private Integer progress; + private String errorMsg; + private LocalDateTime finishTime; +} +``` + +### 数据库字段变更 + +```sql +-- 新增 materials_json 字段,替代原来的 video_urls +ALTER TABLE tik_mix_task +ADD COLUMN materials_json TEXT COMMENT '素材配置JSON'; + +-- 可选:保留 video_urls 做兼容,或迁移后删除 +``` + +--- + +## ☁️ 阿里云ICE集成变更 + +### Timeline 构建逻辑 + +```java +public String buildTimeline(List materials) { + // ICE Timeline 结构 + // 每个素材需要指定: + // 1. MediaURL: 视频源地址 + // 2. In: 开始时间(通常为0) + // 3. Out: 结束时间 = duration + + StringBuilder tracks = new StringBuilder(); + float currentTime = 0; + + for (MaterialItem material : materials) { + tracks.append(String.format(""" + { + "MediaURL": "%s", + "In": 0, + "Out": %d, + "TimelineIn": %.2f, + "TimelineOut": %.2f + } + """, + material.getFileUrl(), + material.getDuration(), + currentTime, + currentTime + material.getDuration() + )); + currentTime += material.getDuration(); + } + + return buildFullTimeline(tracks.toString()); +} +``` + +### ICE 关键参数说明 + +| 参数 | 说明 | +|------|------| +| MediaURL | 素材视频URL | +| In | 素材起始时间(秒),从原视频的哪个时间点开始截取 | +| Out | 素材结束时间(秒),截取到原视频的哪个时间点 | +| TimelineIn | 在输出视频中的起始时间 | +| TimelineOut | 在输出视频中的结束时间 | + +--- + +## ⚠️ 校验规则 + +### 前端校验 + +```javascript +// 时长校验规则 +const validateDuration = () => { + const total = totalDuration.value + + // 1. 总时长不能小于15秒 + if (total < 15) { + return { valid: false, msg: '总时长不足15秒' } + } + + // 2. 总时长不能超过60秒 + if (total > 60) { + return { valid: false, msg: '总时长超过60秒' } + } + + // 3. 至少选择1个素材 + if (Object.keys(selectedMaterials.value).length === 0) { + return { valid: false, msg: '请至少选择1个素材' } + } + + return { valid: true } +} +``` + +### 后端校验 + +```java +public void validateMixTask(MixTaskSaveReqVO req) { + // 1. 素材列表不能为空 + if (req.getMaterials() == null || req.getMaterials().isEmpty()) { + throw new ServiceException("素材列表不能为空"); + } + + // 2. 计算总时长 + int totalDuration = req.getMaterials().stream() + .mapToInt(MaterialItem::getDuration) + .sum(); + + // 3. 总时长校验 + if (totalDuration < 15) { + throw new ServiceException("总时长不能小于15秒"); + } + if (totalDuration > 60) { + throw new ServiceException("总时长不能超过60秒"); + } + + // 4. 单个素材时长校验 + for (MaterialItem item : req.getMaterials()) { + if (item.getDuration() < 3 || item.getDuration() > 15) { + throw new ServiceException("单个素材时长需在3-15秒之间"); + } + } +} +``` + +--- + +## 🔄 完整流程 + +``` +1. 用户进入混剪页面 (/material/mix) + ↓ +2. 选择素材分组 + ↓ +3. 加载分组内的视频素材 + ↓ +4. 点击素材卡片进行选择 + ↓ +5. 为每个选中的素材设置截取时长(3s-15s) + ├─ 默认: 3s + └─ 可选: 3s/5s/8s/10s/15s + ↓ +6. 实时计算并显示总时长 + ├─ 小于15s: 警告提示,禁止提交 + ├─ 15s-60s: 正常,允许提交 + └─ 大于60s: 警告提示,禁止提交 + ↓ +7. 填写视频标题、选择生成数量(1-3个) + ↓ +8. 点击"开始混剪" + ↓ +9. 调用 POST /api/mix/create + { + title: "xxx", + materials: [ + { fileId: 1, fileUrl: "...", duration: 3 }, + { fileId: 2, fileUrl: "...", duration: 5 }, + { fileId: 3, fileUrl: "...", duration: 4 }, + { fileId: 4, fileUrl: "...", duration: 6 }, + { fileId: 5, fileUrl: "...", duration: 5 } + ], + produceCount: 2 + } + ↓ +10. 后端处理:素材分配算法 + ├─ 计算目标时长:23s ÷ 2 = 11.5s ≈ 12s/视频 + ├─ 分配素材: + │ ├─ 视频1: [素材1(随机7s), 素材2(随机4s), 素材3(随机9s)] + │ └─ 视频2: [素材4,5, 素材1,2,3,4,5 (补全所有)] + │ └─ 包含所有9个素材,每个素材随机时长 + ├─ **随机时长生成**:基于素材ID和视频序号生成可重现的随机值 + └─ 构建多个ICE Timeline(每个视频独立Timeline) + ↓ +11. 批量提交到ICE(2个任务) + ├─ 任务1: 视频1的Timeline + ├─ 任务2: 视频2的Timeline + └─ 任务状态:pending → running → success/failed + ↓ +12. 任务完成,生成2个不同内容的视频 + ├─ 视频1: 使用素材1,2,3 + ├─ 视频2: 使用素材4,5,1 + └─ 用户下载/预览 +``` + +--- + +## 📝 实现清单 + +### 前端任务 +- [x] 修改 Mix.vue:素材选择改为 Map 结构存储(fileId → duration) +- [x] 添加时长选择下拉框(每个素材卡片) +- [x] 添加总时长实时计算和显示 +- [x] 添加时长校验警告提示 +- [x] 修改提交数据格式(materials 数组替代 videoUrls) +- [ ] **新增:多视频生成提示** + - [ ] 在页面显示"将生成X个不同内容的视频"提示 + - [ ] 优化生成数量选择UI,突出差异性 + +### 后端任务 +- [x] 修改 MixTaskSaveReqVO:新增 MaterialItem 内部类 +- [x] 修改 MixTaskDO:新增 materialsJson 字段 +- [x] 修改 MixTaskService:添加时长校验逻辑 +- [x] 修改 ICE Timeline 构建:支持每个素材不同时长截取 +- [x] 数据库迁移:新增 materials_json 字段 +- [ ] **新增:多视频生成核心功能** + - [ ] 实现 MaterialDistribution 素材分配算法类 + - [ ] **新增:随机时长生成功能** + - [ ] 实现 generateRandomDuration() 方法 + - [ ] 使用素材ID和视频序号作为随机种子 + - [ ] 确保随机值的可重现性 + - [ ] **新增:最后一个视频补全功能** + - [ ] 实现补全逻辑:最后一个视频包含所有素材 + - [ ] 前(N-1)个视频避免重复使用素材 + - [ ] 循环补全机制:随机选择部分素材再次使用 + - [ ] 修改 createMixTask:支持批量生成不同视频 + - [ ] 修改 submitToICE:循环提交多个视频任务 + - [ ] 修改任务状态管理:支持多任务状态跟踪 + - [ ] 更新任务完成逻辑:合并多个视频的结果 + +### 算法实现任务 +- [ ] **素材分配算法** + - [x] 平均分配算法(默认) + - [x] 交错分配算法(奇偶分配) + - [ ] 智能平衡分配算法 + - [ ] 素材不足时的循环分配处理 +- [ ] **随机时长算法** + - [x] 基于素材ID和视频序号的随机种子生成 + - [x] 3s-15s范围内随机时长生成 + - [x] 可重现性保证(相同输入产生相同输出) +- [ ] **补全逻辑算法** + - [x] 最后一个视频包含所有素材 + - [x] 前(N-1)个视频去重分配 + - [ ] 循环补全策略优化(70%概率循环使用) + +### 容错机制实现任务 +- [ ] **ICE API调用容错** + - [ ] 实现重试机制(最多3次,间隔1s/3s/9s) + - [ ] 部分成功处理:返回成功的视频 + - [ ] 失败补偿:提供手动重试功能 +- [ ] **视频时长不足容错** + - [ ] 自动循环使用素材补充时长 + - [ ] 调整素材截取起始点 + - [ ] 确保最终时长达到目标 +- [ ] **随机时长生成容错** + - [ ] 添加默认时长fallback(5s) + - [ ] 异常日志记录 + - [ ] 继续流程机制 +- [ ] **素材不足容错** + - [ ] 循环使用素材填充 + - [ ] 动态调整素材时长 + - [ ] 保证每个视频至少2个素材 +- [ ] **任务状态容错** + - [ ] 部分成功状态定义(≥1个视频成功) + - [ ] 定期状态同步机制 + - [ ] 补全失败视频功能 + +### 数据模型增强 +- [ ] **新增:视频批次表**(可选) + - [ ] 创建 tik_mix_video_batch 表 + - [ ] 存储每个混剪任务生成的多个视频信息 + - [ ] 记录素材分配结果和时长信息 + +## ⚠️ 重大变更说明 + +### 变更概述 +本次更新引入**多视频差异化生成**功能,这是对原有混剪逻辑的重大升级。 + +### 核心变更点 + +#### 1. 数据流变更 +**原逻辑**: +``` +用户选择素材 → 生成1个视频(使用所有素材) +``` + +**新逻辑**: +``` +用户选择素材 → 分配算法 → 生成N个不同视频(每个视频使用不同素材组合) +``` + +#### 2. 技术实现复杂度 +- **原实现**:单次ICE提交,1个Timeline,1个输出 +- **新实现**:N次ICE提交,N个Timeline,N个输出,需要状态管理 + +#### 3. 资源消耗 +- **CPU**:增加素材分配计算开销 +- **内存**:需要存储多个视频的素材分配信息 +- **API调用**:ICE API调用次数翻倍(N倍) +- **存储**:任务表存储更多jobId和outputUrl + +### 设计权衡 + +#### 优势 +✅ **用户体验提升**:一次操作获得多个不同视频,提高创作效率 +✅ **内容多样性**:避免生成的视频内容重复 +✅ **灵活性**:支持多种素材分配策略,满足不同需求 + +#### 挑战 +⚠️ **复杂度增加**:需要处理多个视频的状态同步 +⚠️ **失败处理**:部分视频失败时的补偿机制 +⚠️ **资源消耗**:N倍ICE调用可能带来成本压力 + +### 兼容性考虑 +- **向后兼容**:现有API保持不变,新增字段可选 +- **数据迁移**:无需迁移,已有任务不受影响 +- **平滑升级**:老版本任务继续使用原有逻辑 + +### 性能优化建议 +1. **并发提交**:多个ICE任务并发提交,提高效率 +2. **状态缓存**:批量查询任务状态,减少API调用 +3. **结果合并**:异步合并多个视频的完成状态 + +--- + +*文件:mix-logic-spec.md* +*基于:阿里云ICE混剪接口 + 素材库系统* +*更新时间:2025-12-14* +*版本:v2.0 - 多视频差异化生成* diff --git a/openspec/professional-simple-mix-spec.md b/openspec/professional-simple-mix-spec.md new file mode 100644 index 0000000000..4fd445da6a --- /dev/null +++ b/openspec/professional-simple-mix-spec.md @@ -0,0 +1,135 @@ +# 混剪功能规格(简化版) + +## 核心需求 + +- **输入**:用户选择素材 + 设定每个素材截取时长(3-15s) +- **输出**:1-3个不同内容的混剪视频 +- **总时长**:15s-60s +- **差异化**:同顺序 + 同时长 + **随机截取起点** + +## 多视频差异化算法 + +### 核心原理 + +**随机起点 + 容错机制**: +- 每个视频使用**随机截取起点**,确保内容完全不同 +- 支持**不同长度的素材**,ICE自动容错处理 +- 容错:如果起点超出素材长度,ICE自动从0开始截取 + +**随机种子**:使用 `素材ID×1000000 + 视频序号×10000 + URL哈希%1000` 确保可重现性 + +### 算法实现 + +**随机起点生成**: +```java +// 1. 先获取视频实际时长 +int actualDuration = getVideoDuration(videoUrl); + +// 2. 生成随机种子 +long randomSeed = (material.getFileId() * 1000000L) + + (videoIndex * 10000L) + + (material.getFileUrl().hashCode() % 1000); +Random random = new Random(randomSeed); + +// 3. 根据实际时长计算起始范围 +int maxStartOffset = Math.max(0, actualDuration - duration); +int startOffset = random.nextInt(maxStartOffset + 1); +int endOffset = startOffset + duration; +``` + +**获取视频时长方案**: +1. **数据库字段**:上传时预存duration字段(推荐) +2. **FFprobe工具**:命令行获取视频元数据 +3. **ICE元数据API**:调用ICE查询接口 +4. **默认60秒**:保守值,兼容性最好 + +**容错机制**: +- 根据实际时长计算最大起始偏移,避免超出素材长度 +- 如果获取时长失败,使用默认值60秒 +- ICE自动处理边界情况 + +### ICE Timeline构建 + +每个素材片段包含参数: +- `MediaURL`:素材地址 +- `In`:随机截取起始点(0到实际时长-duration之间) +- `Out`:截取结束点 = `In + duration` +- `TimelineIn/TimelineOut`:时间轴位置(顺序拼接) + +ICE自动处理超出素材长度的情况,无需额外判断。 + +## API设计 + +### 请求格式 + +```http +POST /api/mix/create +{ + "title": "美食视频", + "materials": [ + { "fileId": 123, "fileUrl": "https://xxx/v1.mp4", "duration": 5 }, + { "fileId": 456, "fileUrl": "https://xxx/v2.mp4", "duration": 8 }, + { "fileId": 789, "fileUrl": "https://xxx/v3.mp4", "duration": 5 } + ], + "produceCount": 3 +} +``` + +### 后端处理流程 + +1. 校验请求参数(总时长15-60s) +2. 循环生成produceCount个视频: + - videoIndex = 0, 1, 2... + - 获取每个素材的实际时长(数据库/FFprobe/ICE API) + - 生成随机起点(基于素材ID×1000000 + videoIndex×10000 + URL哈希) + - 根据实际时长计算起始范围,避免超出素材长度 + - 构建Timeline,传递随机In/Out参数给ICE + - 提交ICE任务 +3. 保存任务并返回任务ID + +## 校验规则 + +| 规则 | 前端 | 后端 | +|------|------|------| +| 总时长 15-60s | ✅ | ✅ | +| 单素材 3-15s | ✅ | ✅ | +| 至少选1个素材 | ✅ | ✅ | +| 生成数量 1-3 | ✅ | ✅ | + +## 实现清单 + +### 已完成 +- [x] 前端时长选择和实时计算 +- [x] 后端VO(MaterialItem)实现 +- [x] 后端DO(materialsJson)字段 +- [x] 数据库迁移脚本 +- [x] 后端Controller(/api/mix/create) +- [x] 后端Service(多视频生成逻辑) +- [x] ICE Timeline构建(随机起点+实际时长+容错) +- [x] 批量任务提交和状态跟踪 + +### 测试验证 +- [ ] 编译验证 +- [ ] 端到端功能测试 +- [ ] 多视频差异化验证 + +--- + +## 代码修改清单 + +### 核心修改 +1. **BatchProduceAlignment.java** + - 新增方法:`produceSingleVideoWithOffset(materials, videoIndex, userId)` + - 新增方法:`getVideoDuration(videoUrl)` - 获取视频实际时长 + - 核心逻辑:先获取实际时长,再生成随机起点 + - 容错机制:根据实际时长计算范围,避免超出长度 + +2. **MixTaskServiceImpl.java** + - 循环生成produceCount个视频 + - 每次传入不同的videoIndex,确保随机起点不同 + +3. **数据库结构(可选改进)** + - 新增字段:`duration INTEGER COMMENT '视频时长(秒)'` + - 上传时预处理:使用FFprobe获取时长并存储 + +*版本:v3.0 - 简化版(ICE自动容错)* diff --git a/openspec/project.md b/openspec/project.md new file mode 100644 index 0000000000..3da5119d0a --- /dev/null +++ b/openspec/project.md @@ -0,0 +1,31 @@ +# Project Context + +## Purpose +[Describe your project's purpose and goals] + +## Tech Stack +- [List your primary technologies] +- [e.g., TypeScript, React, Node.js] + +## Project Conventions + +### Code Style +[Describe your code style preferences, formatting rules, and naming conventions] + +### Architecture Patterns +[Document your architectural decisions and patterns] + +### Testing Strategy +[Explain your testing approach and requirements] + +### Git Workflow +[Describe your branching strategy and commit conventions] + +## Domain Context +[Add domain-specific knowledge that AI assistants need to understand] + +## Important Constraints +[List any technical, business, or regulatory constraints] + +## External Dependencies +[Document key external services, APIs, or systems] diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikUserFileController.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikUserFileController.java index dcfbeab8ab..87fcd31b31 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikUserFileController.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikUserFileController.java @@ -43,8 +43,10 @@ public class AppTikUserFileController { @Parameter(description = "文件分类(video/generate/audio/mix/voice)", required = true) @RequestParam("fileCategory") String fileCategory, @Parameter(description = "视频封面 base64(可选,data URI 格式)") - @RequestParam(value = "coverBase64", required = false) String coverBase64) { - return success(userFileService.uploadFile(file, fileCategory, coverBase64)); + @RequestParam(value = "coverBase64", required = false) String coverBase64, + @Parameter(description = "视频时长(秒)") + @RequestParam(value = "duration", required = false) Integer duration) { + return success(userFileService.uploadFile(file, fileCategory, coverBase64, duration)); } @GetMapping("/page") diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikUserFileDO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikUserFileDO.java index d3daa737f7..ea67f754c9 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikUserFileDO.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikUserFileDO.java @@ -79,5 +79,9 @@ public class TikUserFileDO extends TenantBaseDO { * 文件描述 */ private String description; + /** + * 视频时长(秒) + */ + private Integer duration; } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileService.java index d6bfddf1fb..e518c935b7 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileService.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileService.java @@ -20,9 +20,10 @@ public interface TikUserFileService { * @param file 文件 * @param fileCategory 文件分类(video/generate/audio/mix/voice) * @param coverBase64 视频封面 base64(可选,data URI 格式) + * @param duration 视频时长(秒,可选) * @return 文件编号 */ - Long uploadFile(MultipartFile file, String fileCategory, String coverBase64); + Long uploadFile(MultipartFile file, String fileCategory, String coverBase64, Integer duration); /** * 分页查询文件列表 diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java index 25822c2e49..61a5a1d183 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java @@ -73,7 +73,7 @@ public class TikUserFileServiceImpl implements TikUserFileService { private FileConfigService fileConfigService; @Override - public Long uploadFile(MultipartFile file, String fileCategory, String coverBase64) { + public Long uploadFile(MultipartFile file, String fileCategory, String coverBase64, Integer duration) { Long userId = SecurityFrameworkUtils.getLoginUserId(); Long tenantId = TenantContextHolder.getTenantId(); @@ -151,7 +151,7 @@ public class TikUserFileServiceImpl implements TikUserFileService { // ========== 第三阶段:保存数据库(在事务中,如果失败则删除OSS文件) ========== try { - return saveFileRecord(userId, file, fileCategory, fileUrl, filePath, coverBase64, baseDirectory, infraFileId); + return saveFileRecord(userId, file, fileCategory, fileUrl, filePath, coverBase64, baseDirectory, infraFileId, duration); } catch (Exception e) { // 数据库保存失败,删除已上传的OSS文件 log.error("[uploadFile][保存数据库失败]", e); @@ -165,7 +165,7 @@ public class TikUserFileServiceImpl implements TikUserFileService { */ @Transactional(rollbackFor = Exception.class) public Long saveFileRecord(Long userId, MultipartFile file, String fileCategory, - String fileUrl, String filePath, String coverBase64, String baseDirectory, Long infraFileId) { + String fileUrl, String filePath, String coverBase64, String baseDirectory, Long infraFileId, Integer duration) { // 7. 验证 infraFileId 不为空(必须在保存记录之前检查) if (infraFileId == null) { log.error("[saveFileRecord][infra_file.id 为空,无法保存文件记录,用户({}),URL({})]", userId, fileUrl); @@ -231,7 +231,8 @@ public class TikUserFileServiceImpl implements TikUserFileService { .setFileUrl(fileUrl) .setFilePath(filePath) // 保存完整的OSS路径(由FileService生成) .setCoverUrl(coverUrl) // 设置封面URL(如果有) - .setCoverBase64(StrUtil.isNotBlank(coverBase64) ? coverBase64 : null); // 保存原始base64数据(如果有) + .setCoverBase64(StrUtil.isNotBlank(coverBase64) ? coverBase64 : null) // 保存原始base64数据(如果有) + .setDuration(duration); // 设置视频时长(如果有) userFileMapper.insert(userFile); diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileRespVO.java index ce682fcb99..728af1b615 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileRespVO.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileRespVO.java @@ -56,6 +56,9 @@ public class AppTikUserFileRespVO { @Schema(description = "文件描述") private String description; + @Schema(description = "视频时长(秒)") + private Integer duration; + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) private LocalDateTime createTime; diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileUploadReqVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileUploadReqVO.java index 08667761cb..affc5198e9 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileUploadReqVO.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileUploadReqVO.java @@ -24,5 +24,8 @@ public class AppTikUserFileUploadReqVO { @Schema(description = "文件描述", example = "测试视频") private String description; + @Schema(description = "视频时长(秒)", example = "60") + private Integer duration; + } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignment.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignment.java index a99608cff2..75a38533fb 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignment.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignment.java @@ -12,6 +12,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import cn.iocoder.yudao.module.tik.mix.vo.MixTaskSaveReqVO; + import java.util.*; // 成功视频 @@ -205,4 +207,193 @@ public class BatchProduceAlignment { return jobIdWithUrl.split(" : ")[1]; } + /** + * 计算裁剪参数 + * + * @param sourceWidth 源素材宽度 + * @param sourceHeight 源素材高度 + * @param cropMode 裁剪模式:center(居中裁剪)、smart(智能裁剪)、fill(填充模式) + * @return 裁剪参数Map,包含X、Y、Width、Height + */ + private Map calculateCropParams(int sourceWidth, int sourceHeight, String cropMode) { + Map cropParams = new HashMap<>(); + double targetRatio = 9.0 / 16.0; // 9:16竖屏比例 + + if ("fill".equals(cropMode)) { + // 填充模式:不裁剪,保持原尺寸 + cropParams.put("X", 0); + cropParams.put("Y", 0); + cropParams.put("Width", sourceWidth); + cropParams.put("Height", sourceHeight); + } else if ("smart".equals(cropMode)) { + // 智能裁剪功能暂未开放,自动降级为居中裁剪 + log.info("[裁剪模式] smart模式暂未开放,自动降级为center模式"); + double cropHeight = sourceHeight; + double cropWidth = cropHeight * targetRatio; + int cropX = (int) Math.round((sourceWidth - cropWidth) / 2); + int cropY = 0; + + cropParams.put("X", cropX); + cropParams.put("Y", cropY); + cropParams.put("Width", (int) Math.round(cropWidth)); + cropParams.put("Height", (int) Math.round(cropHeight)); + } else { + // center模式:居中裁剪(默认) + double cropHeight = sourceHeight; + double cropWidth = cropHeight * targetRatio; + int cropX = (int) Math.round((sourceWidth - cropWidth) / 2); + int cropY = 0; + + cropParams.put("X", cropX); + cropParams.put("Y", cropY); + cropParams.put("Width", (int) Math.round(cropWidth)); + cropParams.put("Height", (int) Math.round(cropHeight)); + } + + log.debug("[裁剪计算] 源尺寸={}x{}, 模式={}, 裁剪参数={}", sourceWidth, sourceHeight, cropMode, cropParams); + return cropParams; + } + + /** + * 生成单个视频(支持随机截取起始点) + * + * 多视频差异化原理: + * - 每个视频使用随机截取起点,确保内容完全不同 + * - 支持不同长度的素材,ICE自动容错处理 + * - 容错机制:如果起点超出素材长度,从0开始截取 + * + * @param materials 素材列表(包含fileUrl和duration) + * @param videoIndex 视频序号(0开始),用于生成随机种子 + * @param userId 用户ID + * @param cropMode 裁剪模式:center(居中裁剪)、smart(智能裁剪)、fill(填充模式) + * @return jobId : outputUrl 格式字符串 + */ + public String produceSingleVideoWithOffset(List materials, + int videoIndex, Long userId, String cropMode) throws Exception { + if (iceClient == null) { + initClient(); + } + + JSONArray videoClipArray = new JSONArray(); + JSONArray audioClipArray = new JSONArray(); + float timelinePos = 0; + + for (int i = 0; i < materials.size(); i++) { + MixTaskSaveReqVO.MaterialItem material = materials.get(i); + String videoUrl = material.getFileUrl(); + int duration = material.getDuration(); + + // 验证视频URL必须是阿里云OSS地址 + if (!videoUrl.contains(".aliyuncs.com")) { + log.error("[ICE][视频URL不是阿里云OSS地址][视频{}: {}]", i + 1, videoUrl); + throw new IllegalArgumentException("视频URL必须是阿里云OSS地址,当前URL: " + videoUrl); + } + + // 计算随机截取起点 + // 优先使用前端传入的素材实际时长,无则从0开始截取(兜底) + Integer fileDuration = material.getFileDuration(); + int startOffset = 0; + int endOffset = duration; + + if (fileDuration != null && fileDuration > duration) { + // 有实际时长且足够:随机起点范围 0 到 (实际时长 - 截取时长) + long randomSeed = ((material.getFileId() != null ? material.getFileId() : i) * 1000000L) + + (videoIndex * 10000L) + (material.getFileUrl().hashCode() % 1000); + Random random = new Random(randomSeed); + int maxStartOffset = fileDuration - duration; + startOffset = random.nextInt(maxStartOffset + 1); + endOffset = startOffset + duration; + log.debug("[ICE][随机截取] fileId={}, fileDuration={}s, In={}, Out={}", + material.getFileId(), fileDuration, startOffset, endOffset); + } else { + // 无时长或时长不足:从0开始截取(兜底) + log.debug("[ICE][兜底截取] fileId={}, fileDuration={}, In=0, Out={}", + material.getFileId(), fileDuration, duration); + } + + log.debug("[ICE][添加视频片段][视频{}: {}, In={}, Out={}, TimelineIn={}, TimelineOut={}]", + videoIndex + 1, videoUrl, startOffset, endOffset, timelinePos, timelinePos + duration); + + // 构建视频片段(带 In/Out 参数) + JSONObject videoClip = new JSONObject(); + videoClip.put("MediaURL", videoUrl); + videoClip.put("In", startOffset); + videoClip.put("Out", endOffset); + videoClip.put("TimelineIn", timelinePos); + videoClip.put("TimelineOut", timelinePos + duration); + + // 添加裁剪效果(9:16竖屏输出) + // 假设源素材为1920x1080(16:9),可根据实际情况调整 + int sourceWidth = 1920; + int sourceHeight = 1080; + + if (cropMode != null && !"fill".equals(cropMode)) { + // 非填充模式需要裁剪 + Map cropParams = calculateCropParams(sourceWidth, sourceHeight, cropMode); + + JSONArray effects = new JSONArray(); + JSONObject cropEffect = new JSONObject(); + cropEffect.put("Type", "Crop"); + cropEffect.put("X", cropParams.get("X")); + cropEffect.put("Y", cropParams.get("Y")); + cropEffect.put("Width", cropParams.get("Width")); + cropEffect.put("Height", cropParams.get("Height")); + effects.add(cropEffect); + + videoClip.put("Effects", effects); + log.debug("[裁剪效果] 视频{}应用裁剪,模式={}, 参数={}", i + 1, cropMode, cropParams); + } + + videoClipArray.add(videoClip); + + // 为每个视频片段添加静音的音频轨道 + JSONObject audioClip = new JSONObject(); + audioClip.put("MediaURL", videoUrl); + audioClip.put("In", startOffset); + audioClip.put("Out", endOffset); + audioClip.put("TimelineIn", timelinePos); + audioClip.put("TimelineOut", timelinePos + duration); + audioClip.put("Effects", new JSONArray() {{ + add(new JSONObject() {{ + put("Type", "Volume"); + put("Gain", 0); // 静音 + }}); + }}); + audioClipArray.add(audioClip); + + timelinePos += duration; + } + + // 构建时间线 + String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":" + videoClipArray.toJSONString() + + "}],\"AudioTracks\":[{\"AudioTrackClips\":" + audioClipArray.toJSONString() + "}]}"; + + // 生成输出文件路径 + String targetFileName = UUID.randomUUID().toString().replace("-", ""); + String mixDirectory = ossInitService.getOssDirectoryByCategory(userId, "mix"); + String dateDir = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); + String outputMediaPath = mixDirectory + "/" + dateDir + "/" + targetFileName + ".mp4"; + + String bucketEndpoint = "https://" + properties.getBucket() + ".oss-" + properties.getRegionId() + ".aliyuncs.com"; + String outputMediaUrl = bucketEndpoint + "/" + outputMediaPath; + + int width = 720; + int height = 1280; + int bitrate = 2000; + String outputMediaConfig = "{\"MediaURL\":\"" + outputMediaUrl + "\",\"Width\":" + width + + ",\"Height\":" + height + ",\"Bitrate\":" + bitrate + "}"; + + SubmitMediaProducingJobRequest request = new SubmitMediaProducingJobRequest(); + request.setTimeline(timeline); + request.setOutputMediaConfig(outputMediaConfig); + + log.info("[ICE][提交任务][videoIndex={}, 素材数量={}, 总时长={}s]", + videoIndex, materials.size(), (int)timelinePos); + SubmitMediaProducingJobResponse response = iceClient.submitMediaProducingJob(request); + + String jobId = response.getBody().getJobId(); + log.info("[ICE][任务提交成功][videoIndex={}, jobId={}, outputUrl={}]", videoIndex, jobId, outputMediaUrl); + return jobId + " : " + outputMediaUrl; + } + } \ No newline at end of file diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/constants/MixTaskConstants.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/constants/MixTaskConstants.java index c0149c91da..7e13823d5a 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/constants/MixTaskConstants.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/constants/MixTaskConstants.java @@ -24,9 +24,9 @@ public class MixTaskConstants { /** * 定时任务配置 - * 改为每2分钟检查一次,降低API调用频率 + * 改为每30秒检查一次,提供更实时的进度更新 */ - public static final String CRON_CHECK_STATUS = "0 */2 * * * ?"; + public static final String CRON_CHECK_STATUS = "*/30 * * * * ?"; /** * 任务状态检查优化配置 diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/dal/dataobject/MixTaskDO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/dal/dataobject/MixTaskDO.java index 60e8d9d624..1963da469c 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/dal/dataobject/MixTaskDO.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/dal/dataobject/MixTaskDO.java @@ -46,6 +46,12 @@ public class MixTaskDO extends TenantBaseDO { @TableField("video_urls") private String videoUrls; + /** + * 素材配置JSON(包含fileId、fileUrl、duration) + */ + @TableField("materials_json") + private String materialsJson; + /** * 背景音乐URL列表(逗号分隔) */ @@ -162,4 +168,18 @@ public class MixTaskDO extends TenantBaseDO { public void setOutputUrlList(List outputUrls) { this.outputUrls = outputUrls == null || outputUrls.isEmpty() ? null : String.join(",", outputUrls); } + + /** + * 获取素材配置JSON + */ + public String getMaterialsJson() { + return materialsJson; + } + + /** + * 设置素材配置JSON + */ + public void setMaterialsJson(String materialsJson) { + this.materialsJson = materialsJson; + } } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java index 13ca325bc4..3553856032 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.tik.mix.service; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.module.infra.service.file.FileService; import cn.iocoder.yudao.module.tik.mix.client.IceClient; @@ -46,8 +47,11 @@ public class MixTaskServiceImpl implements MixTaskService { @Override @Transactional(rollbackFor = Exception.class) public Long createMixTask(MixTaskSaveReqVO createReqVO, Long userId) { - log.info("[MixTask][创建任务] userId={}, title={}, videoCount={}, produceCount={}", - userId, createReqVO.getTitle(), createReqVO.getVideoUrls().size(), createReqVO.getProduceCount()); + // 1. 校验时长 + validateDuration(createReqVO); + + log.info("[MixTask][创建任务] userId={}, title={}, materialCount={}, produceCount={}", + userId, createReqVO.getTitle(), createReqVO.getMaterials().size(), createReqVO.getProduceCount()); // 1. 创建初始任务对象 MixTaskDO task = MixTaskUtils.createInitialTask(createReqVO, userId); @@ -168,10 +172,29 @@ public class MixTaskServiceImpl implements MixTaskService { // 3. 重新提交到ICE CompletableFuture.runAsync(() -> { try { - // 手动构建请求对象(纯画面模式:无需text和bgMusicUrls) + // 从 materialsJson 重建请求对象 + List materials = null; + if (StrUtil.isNotEmpty(existTask.getMaterialsJson())) { + materials = JsonUtils.parseArray(existTask.getMaterialsJson(), MixTaskSaveReqVO.MaterialItem.class); + } else if (existTask.getVideoUrlList() != null && !existTask.getVideoUrlList().isEmpty()) { + // 兼容旧版本:从 videoUrls 重建(默认3秒时长) + materials = existTask.getVideoUrlList().stream() + .map(url -> { + MixTaskSaveReqVO.MaterialItem item = new MixTaskSaveReqVO.MaterialItem(); + item.setFileUrl(url); + item.setDuration(3); // 默认3秒 + return item; + }) + .collect(ArrayList::new, ArrayList::add, ArrayList::addAll); + } + + if (materials == null || materials.isEmpty()) { + throw new IllegalArgumentException("无法重建素材列表"); + } + MixTaskSaveReqVO saveReqVO = new MixTaskSaveReqVO(); saveReqVO.setTitle(existTask.getTitle()); - saveReqVO.setVideoUrls(existTask.getVideoUrlList()); + saveReqVO.setMaterials(materials); saveReqVO.setProduceCount(existTask.getProduceCount()); submitToICE(id, saveReqVO, existTask.getUserId()); } catch (Exception e) { @@ -353,24 +376,32 @@ public class MixTaskServiceImpl implements MixTaskService { /** * 提交任务到阿里云 ICE + * + * 多视频差异化逻辑: + * - 每个视频使用相同的素材顺序和时长 + * - 但截取起始点不同(videoIndex * duration) + * - 生成内容不同的多个视频 */ private void submitToICE(Long taskId, MixTaskSaveReqVO createReqVO, Long userId) { try { - // 1. 转换为ICE需要的参数格式 - String[] videoArray = createReqVO.getVideoUrls().toArray(new String[0]); + List jobIdWithUrls = new ArrayList<>(); + int produceCount = createReqVO.getProduceCount(); - // 2. 调用ICE批量生成接口(纯画面模式:无需text和bgMusic) - List jobIdWithUrls = batchProduceAlignment.batchProduceAlignment( - createReqVO.getTitle(), - videoArray, - createReqVO.getProduceCount(), - userId - ); + // 循环生成多个视频,每个视频使用不同的截取起始点 + for (int videoIndex = 0; videoIndex < produceCount; videoIndex++) { + String jobIdWithUrl = batchProduceAlignment.produceSingleVideoWithOffset( + createReqVO.getMaterials(), + videoIndex, + userId, + createReqVO.getCropMode() + ); + jobIdWithUrls.add(jobIdWithUrl); + } - // 3. 解析jobId和输出URL + // 解析jobId和输出URL MixTaskUtils.JobIdUrlPair jobIdUrlPair = MixTaskUtils.parseJobIdsAndUrls(jobIdWithUrls); - // 4. 更新任务信息(包含状态和进度) + // 更新任务信息 updateTaskWithResults(taskId, jobIdUrlPair.getJobIds(), jobIdUrlPair.getOutputUrls(), MixTaskConstants.STATUS_RUNNING, MixTaskConstants.PROGRESS_UPLOADED); @@ -498,4 +529,36 @@ public class MixTaskServiceImpl implements MixTaskService { } }); } + + /** + * 校验混剪任务时长 + */ + private void validateDuration(MixTaskSaveReqVO req) { + // 1. 素材列表不能为空 + if (req.getMaterials() == null || req.getMaterials().isEmpty()) { + throw new IllegalArgumentException("素材列表不能为空"); + } + + // 2. 计算总时长 + int totalDuration = req.getMaterials().stream() + .mapToInt(MixTaskSaveReqVO.MaterialItem::getDuration) + .sum(); + + // 3. 总时长校验(15s-30s) + if (totalDuration < 15) { + throw new IllegalArgumentException("总时长不能小于15秒,当前:" + totalDuration + "秒"); + } + if (totalDuration > 30) { + throw new IllegalArgumentException("总时长不能超过30秒,当前:" + totalDuration + "秒"); + } + + // 4. 单个素材时长校验(3s-5s) + for (MixTaskSaveReqVO.MaterialItem item : req.getMaterials()) { + if (item.getDuration() < 3 || item.getDuration() > 5) { + throw new IllegalArgumentException("单个素材时长需在3-5秒之间,当前:" + item.getDuration() + "秒"); + } + } + + log.info("[MixTask][时长校验通过] totalDuration={}s, materialCount={}", totalDuration, req.getMaterials().size()); + } } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/util/MixTaskUtils.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/util/MixTaskUtils.java index 522abf62ba..e1cf4841c3 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/util/MixTaskUtils.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/util/MixTaskUtils.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.tik.mix.util; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.module.tik.mix.constants.MixTaskConstants; import cn.iocoder.yudao.module.tik.mix.dal.dataobject.MixTaskDO; import cn.iocoder.yudao.module.tik.mix.vo.MixTaskSaveReqVO; @@ -27,7 +28,19 @@ public class MixTaskUtils { task.setUserId(userId); task.setTitle(reqVO.getTitle()); task.setText(null); // 纯画面模式,不需要文案 - task.setVideoUrlList(reqVO.getVideoUrls()); + + // 存储素材配置JSON + String materialsJson = JsonUtils.toJsonString(reqVO.getMaterials()); + task.setMaterialsJson(materialsJson); + + // 兼容旧版本:同时存储 videoUrls(取第一个视频的URL,用于兼容查询) + if (reqVO.getMaterials() != null && !reqVO.getMaterials().isEmpty()) { + List videoUrls = reqVO.getMaterials().stream() + .map(MixTaskSaveReqVO.MaterialItem::getFileUrl) + .collect(ArrayList::new, ArrayList::add, ArrayList::addAll); + task.setVideoUrlList(videoUrls); + } + task.setBgMusicUrlList(null); // 纯画面模式,不需要背景音乐 task.setProduceCount(reqVO.getProduceCount()); task.setStatus(MixTaskConstants.STATUS_PENDING); @@ -127,4 +140,54 @@ public class MixTaskUtils { return outputUrls; } } + + /** + * 构建 ICE Timeline + * + * @param materials 素材列表 + * @return ICE Timeline JSON 字符串 + */ + public static String buildTimeline(List materials) { + StringBuilder tracks = new StringBuilder(); + float currentTime = 0; + + for (int i = 0; i < materials.size(); i++) { + MixTaskSaveReqVO.MaterialItem material = materials.get(i); + if (i > 0) { + tracks.append(","); + } + + tracks.append(String.format(""" + { + "MediaURL": "%s", + "In": 0, + "Out": %d, + "TimelineIn": %.2f, + "TimelineOut": %.2f + } + """, + material.getFileUrl(), + material.getDuration(), + currentTime, + currentTime + material.getDuration() + )); + + currentTime += material.getDuration(); + } + + return buildFullTimeline(tracks.toString()); + } + + /** + * 构建完整的 ICE Timeline + */ + private static String buildFullTimeline(String tracks) { + return String.format(""" + { + "VideoTracks": [{ + "TrackItems": [%s] + }] + } + """, tracks); + } } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/vo/MixTaskSaveReqVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/vo/MixTaskSaveReqVO.java index 8b0dfbdd63..3078ffd0ff 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/vo/MixTaskSaveReqVO.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/vo/MixTaskSaveReqVO.java @@ -3,6 +3,8 @@ package cn.iocoder.yudao.module.tik.mix.vo; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; @@ -16,11 +18,36 @@ public class MixTaskSaveReqVO { @NotBlank(message = "视频标题不能为空") private String title; - @Schema(description = "视频素材URL列表", required = true) - @NotEmpty(message = "视频素材不能为空") - private List videoUrls; + @Schema(description = "素材配置列表", required = true) + @NotEmpty(message = "素材列表不能为空") + private List materials; @Schema(description = "生成数量", required = true, example = "1") @NotNull(message = "生成数量不能为空") private Integer produceCount = 1; // 默认生成1个 + + @Schema(description = "裁剪模式", example = "center") + private String cropMode = "center"; // 默认居中裁剪 + + @Schema(description = "素材项") + @Data + public static class MaterialItem { + + @Schema(description = "素材文件ID", required = true, example = "12345") + @NotNull(message = "素材文件ID不能为空") + private Long fileId; + + @Schema(description = "素材URL", required = true, example = "https://xxx.com/video1.mp4") + @NotBlank(message = "素材URL不能为空") + private String fileUrl; + + @Schema(description = "截取时长(秒)", required = true, example = "3") + @Min(value = 3, message = "单个素材时长不能小于3秒") + @Max(value = 5, message = "单个素材时长不能超过5秒") + @NotNull(message = "素材时长不能为空") + private Integer duration; + + @Schema(description = "素材实际时长(秒)", example = "60") + private Integer fileDuration; + } } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/config/CosyVoiceProperties.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/config/CosyVoiceProperties.java index 6ee363dc26..f795a15ad2 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/config/CosyVoiceProperties.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/config/CosyVoiceProperties.java @@ -61,9 +61,9 @@ public class CosyVoiceProperties { private Duration connectTimeout = Duration.ofSeconds(10); /** - * 读取超时时间 + * 读取超时时间(改为3分钟,提升语音合成成功率) */ - private Duration readTimeout = Duration.ofSeconds(60); + private Duration readTimeout = Duration.ofSeconds(180); /** * 是否启用 diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/config/LatentsyncProperties.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/config/LatentsyncProperties.java index e5068084af..b1d728f820 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/config/LatentsyncProperties.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/config/LatentsyncProperties.java @@ -56,9 +56,9 @@ public class LatentsyncProperties { private Duration connectTimeout = Duration.ofSeconds(10); /** - * 读取超时时间 + * 读取超时时间(改为3分钟,提升语音合成成功率) */ - private Duration readTimeout = Duration.ofSeconds(60); + private Duration readTimeout = Duration.ofSeconds(180); /** * 是否打开调用 diff --git a/yudao-module-tik/src/test/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignmentTest.java b/yudao-module-tik/src/test/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignmentTest.java new file mode 100644 index 0000000000..384e966a88 --- /dev/null +++ b/yudao-module-tik/src/test/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignmentTest.java @@ -0,0 +1,110 @@ +package cn.iocoder.yudao.module.tik.media; + +import cn.iocoder.yudao.module.tik.file.service.TikOssInitService; +import cn.iocoder.yudao.module.tik.mix.config.IceProperties; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * BatchProduceAlignment 单元测试 + */ +@ExtendWith(MockitoExtension.class) +class BatchProduceAlignmentTest { + + @Mock + private IceProperties iceProperties; + + @Mock + private TikOssInitService ossInitService; + + private BatchProduceAlignment batchProduceAlignment; + + @BeforeEach + void setUp() { + batchProduceAlignment = new BatchProduceAlignment(iceProperties, null, ossInitService); + } + + @Test + void testCalculateCropParams_centerMode() { + // 16:9横屏素材 (1920x1080) -> 9:16竖屏裁剪 + Map cropParams = callCalculateCropParams(1920, 1080, "center"); + + // 验证居中裁剪参数 + assertThat(cropParams.get("X")).isEqualTo(656); // (1920 - 608) / 2 + assertThat(cropParams.get("Y")).isZero(); + assertThat(cropParams.get("Width")).isEqualTo(608); // 1080 * (9/16) + assertThat(cropParams.get("Height")).isEqualTo(1080); + } + + @Test + void testCalculateCropParams_smartMode() { + // smart模式目前实现与center相同 + Map cropParams = callCalculateCropParams(1920, 1080, "smart"); + + assertThat(cropParams.get("X")).isEqualTo(656); + assertThat(cropParams.get("Y")).isZero(); + assertThat(cropParams.get("Width")).isEqualTo(608); + assertThat(cropParams.get("Height")).isEqualTo(1080); + } + + @Test + void testCalculateCropParams_fillMode() { + // fill模式不裁剪,保留原尺寸 + Map cropParams = callCalculateCropParams(1920, 1080, "fill"); + + assertThat(cropParams.get("X")).isZero(); + assertThat(cropParams.get("Y")).isZero(); + assertThat(cropParams.get("Width")).isEqualTo(1920); + assertThat(cropParams.get("Height")).isEqualTo(1080); + } + + @Test + void testCalculateCropParams_differentAspectRatios() { + // 测试不同分辨率的横屏素材 + Map cropParams1 = callCalculateCropParams(1280, 720, "center"); + assertThat(cropParams1.get("Width")).isEqualTo(405); // 720 * (9/16) + assertThat(cropParams1.get("Height")).isEqualTo(720); + + // 测试正方形素材 + Map cropParams2 = callCalculateCropParams(1080, 1080, "center"); + assertThat(cropParams2.get("Width")).isEqualTo(608); // 1080 * (9/16) + assertThat(cropParams2.get("Height")).isEqualTo(1080); + } + + @Test + void testCalculateCropParams_defaultToCenter() { + // 默认应该是center模式 + Map cropParams = callCalculateCropParams(1920, 1080, null); + + assertThat(cropParams.get("X")).isEqualTo(656); + assertThat(cropParams.get("Y")).isZero(); + assertThat(cropParams.get("Width")).isEqualTo(608); + assertThat(cropParams.get("Height")).isEqualTo(1080); + } + + /** + * 通过反射调用私有方法 calculateCropParams + */ + private Map callCalculateCropParams(int sourceWidth, int sourceHeight, String cropMode) { + try { + java.lang.reflect.Method method = BatchProduceAlignment.class.getDeclaredMethod( + "calculateCropParams", int.class, int.class, String.class); + method.setAccessible(true); + @SuppressWarnings("unchecked") + Map result = (Map) method.invoke( + batchProduceAlignment, sourceWidth, sourceHeight, cropMode); + return result; + } catch (Exception e) { + throw new RuntimeException("Failed to invoke calculateCropParams via reflection", e); + } + } +}