diff --git a/docs/PRODUCT_PLAN_AND_STATUS.md b/docs/PRODUCT_PLAN_AND_STATUS.md index c948eb1..652e55d 100644 --- a/docs/PRODUCT_PLAN_AND_STATUS.md +++ b/docs/PRODUCT_PLAN_AND_STATUS.md @@ -119,6 +119,8 @@ Codex / Claude Code / Other Agent - 界面支持英语、中文和日语,并可在主页、项目列表及工作区顶栏切换;英文使用无前缀路径,中文和日语分别使用 `/zh` 与 `/ja`。 - Canvas Snapshot 自动保存、页面隐藏时刷新和版本冲突保护。 - 本地上传图片、视频、音频后立即复制到项目目录。 +- 本地项目完整配音可通过浏览器和 MCP 导入,音频上限 128 MiB;模型参考音频仍遵守独立的 15 MiB 限制。 +- 项目素材列表按项目成员关系返回完整媒体,不再因全局最近 30 条的预览上限隐藏早期素材;全局最近素材保留每类 30 条上限。 - 生成图片、视频和封面在展示前本地化。 - 项目 Assets 和生成历史索引。 - BeatAPI 与存储凭证保留在服务端。 diff --git a/src/core/effects/validation.ts b/src/core/effects/validation.ts index da27db1..e9202c3 100644 --- a/src/core/effects/validation.ts +++ b/src/core/effects/validation.ts @@ -333,15 +333,15 @@ export const validateUploadedVideoFile = (file: { return { ok: true }; }; -export const validateUploadedAudioFile = (file: { - size: number; - type: string; -}): UploadedAudioValidationSuccess | UploadedAudioValidationFailure => { - if (file.size > REFERENCE_AUDIO_MAX_FILE_SIZE) { +export const validateUploadedAudioFile = ( + file: { size: number; type: string }, + { maxBytes = REFERENCE_AUDIO_MAX_FILE_SIZE }: { maxBytes?: number } = {} +): UploadedAudioValidationSuccess | UploadedAudioValidationFailure => { + if (file.size > maxBytes) { return { ok: false, code: 'AUDIO_TOO_LARGE', - maxBytes: REFERENCE_AUDIO_MAX_FILE_SIZE, + maxBytes, }; } @@ -353,7 +353,7 @@ export const validateUploadedAudioFile = (file: { return { ok: false, code: 'AUDIO_TYPE_UNSUPPORTED', - maxBytes: REFERENCE_AUDIO_MAX_FILE_SIZE, + maxBytes, allowedTypes: ALLOWED_AUDIO_MIME_TYPES, }; } diff --git a/src/core/projects/import-local-asset.test.ts b/src/core/projects/import-local-asset.test.ts index b5ffc88..8264e72 100644 --- a/src/core/projects/import-local-asset.test.ts +++ b/src/core/projects/import-local-asset.test.ts @@ -1,7 +1,35 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { importLocalProjectAsset } from './import-local-asset'; +import { + importLocalProjectAsset, + validateLocalAssetImportCandidate, + LOCAL_PROJECT_AUDIO_MAX_FILE_SIZE, +} from './import-local-asset'; +import { validateUploadedAudioFile } from '@/core/effects/validation'; + +test('full narration stems import locally without relaxing generation reference limits', () => { + const size = 34_272_044; + assert.equal(validateLocalAssetImportCandidate({ filename: 'effects-stem.wav', size }).mediaType, 'audio'); + assert.equal(validateUploadedAudioFile({ size, type: 'audio/wav' }).ok, false); + assert.equal(validateUploadedAudioFile({ size: 15 * 1024 * 1024, type: 'audio/wav' }).ok, true); + assert.equal(validateUploadedAudioFile({ size: 15 * 1024 * 1024 + 1, type: 'audio/wav' }).ok, false); + assert.equal(validateLocalAssetImportCandidate({ filename: 'stem.wav', size: LOCAL_PROJECT_AUDIO_MAX_FILE_SIZE }).mediaType, 'audio'); + assert.throws(() => validateLocalAssetImportCandidate({ filename: 'stem.wav', size: LOCAL_PROJECT_AUDIO_MAX_FILE_SIZE + 1 }), /size is not supported/); + assert.throws(() => validateLocalAssetImportCandidate({ filename: 'script.exe', size }), /Unsupported project asset type/); +}); + +test('a larger local audio allowance still rejects unsupported MIME types', () => { + const result = validateUploadedAudioFile( + { size: 34_272_044, type: 'application/octet-stream' }, + { maxBytes: LOCAL_PROJECT_AUDIO_MAX_FILE_SIZE } + ); + assert.equal(result.ok, false); + if (!result.ok) { + assert.equal(result.code, 'AUDIO_TYPE_UNSUPPORTED'); + assert.equal(result.maxBytes, LOCAL_PROJECT_AUDIO_MAX_FILE_SIZE); + } +}); test('asset import rejects a relative file path', async () => { await assert.rejects( diff --git a/src/core/projects/import-local-asset.ts b/src/core/projects/import-local-asset.ts index fb7134c..92a9263 100644 --- a/src/core/projects/import-local-asset.ts +++ b/src/core/projects/import-local-asset.ts @@ -9,6 +9,7 @@ import { validateUploadedVideoFile, } from '@/core/effects/validation'; import { isSupportedRasterImage } from '@/lib/image-upload-validation'; +import { LOCAL_PROJECT_AUDIO_MAX_FILE_SIZE } from '@/core/workspace-lib/constants'; import { getProject } from '@/core/projects/projects'; import { LOCAL_PROJECT_ASSET_BUCKET, @@ -22,6 +23,10 @@ import { recordUserAsset, } from '@/core/workspace-lib/assets/user-assets'; +// Project stems are longer than model reference clips. Provider reference limits +// remain enforced independently at generation/upload preflight. +export { LOCAL_PROJECT_AUDIO_MAX_FILE_SIZE } from '@/core/workspace-lib/constants'; + export function validateLocalAssetImportCandidate({ filename, size, @@ -41,7 +46,7 @@ export function validateLocalAssetImportCandidate({ ? validateUploadedImageFile(file) : mediaType === 'video' ? validateUploadedVideoFile(file) - : validateUploadedAudioFile(file); + : validateUploadedAudioFile(file, { maxBytes: LOCAL_PROJECT_AUDIO_MAX_FILE_SIZE }); if (!validation.ok) { throw new Error('Project asset type or size is not supported.'); } diff --git a/src/core/workspace-lib/constants.ts b/src/core/workspace-lib/constants.ts index 2aba84b..9546280 100644 --- a/src/core/workspace-lib/constants.ts +++ b/src/core/workspace-lib/constants.ts @@ -2,3 +2,4 @@ export const AVATAR_MAX_FILE_SIZE = 2 * 1024 * 1024; export const REFERENCE_IMAGE_MAX_FILE_SIZE = 50 * 1024 * 1024; export const REFERENCE_VIDEO_MAX_FILE_SIZE = 100 * 1024 * 1024; export const REFERENCE_AUDIO_MAX_FILE_SIZE = 15 * 1024 * 1024; +export const LOCAL_PROJECT_AUDIO_MAX_FILE_SIZE = 128 * 1024 * 1024; diff --git a/src/routes/api/app/projects/$projectId/assets/index.ts b/src/routes/api/app/projects/$projectId/assets/index.ts index f124243..20642fa 100644 --- a/src/routes/api/app/projects/$projectId/assets/index.ts +++ b/src/routes/api/app/projects/$projectId/assets/index.ts @@ -6,9 +6,9 @@ import { validateUploadedImageFile, validateUploadedAudioFile, validateUploadedVideoFile, - REFERENCE_AUDIO_MAX_FILE_SIZE, REFERENCE_VIDEO_MAX_FILE_SIZE, } from '@/core/effects/validation'; +import { LOCAL_PROJECT_AUDIO_MAX_FILE_SIZE } from '@/core/workspace-lib/constants'; import { LOCAL_PROJECT_ASSET_BUCKET, LOCAL_PROJECT_ASSET_PROVIDER, @@ -57,7 +57,7 @@ async function POST({ try { const formData = await readRequestFormDataWithLimit( request, - Math.max(REFERENCE_VIDEO_MAX_FILE_SIZE, REFERENCE_AUDIO_MAX_FILE_SIZE) + + Math.max(REFERENCE_VIDEO_MAX_FILE_SIZE, LOCAL_PROJECT_AUDIO_MAX_FILE_SIZE) + MAX_MULTIPART_OVERHEAD_BYTES ); const file = formData.get('file'); @@ -101,7 +101,7 @@ async function POST({ ? validateUploadedImageFile(file) : mediaType === 'video' ? validateUploadedVideoFile(file) - : validateUploadedAudioFile(file); + : validateUploadedAudioFile(file, { maxBytes: LOCAL_PROJECT_AUDIO_MAX_FILE_SIZE }); if (!validation.ok) { return Response.json( { error: 'Project asset type or size is not supported' }, diff --git a/src/routes/api/app/recent-assets.ts b/src/routes/api/app/recent-assets.ts index 1296dca..cbef771 100644 --- a/src/routes/api/app/recent-assets.ts +++ b/src/routes/api/app/recent-assets.ts @@ -48,7 +48,6 @@ async function GET({ request }: { request: Request }) { ) ) .orderBy(desc(userAsset.createdAt)) - .limit(30) : db .select(imageFields) .from(userAsset) @@ -77,7 +76,6 @@ async function GET({ request }: { request: Request }) { ) ) .orderBy(desc(userAsset.createdAt)) - .limit(30) : db .select(videoFields) .from(userAsset) @@ -106,7 +104,6 @@ async function GET({ request }: { request: Request }) { ) ) .orderBy(desc(userAsset.createdAt)) - .limit(30) : db .select(videoFields) .from(userAsset) diff --git a/tests/e2e/local-assets.spec.ts b/tests/e2e/local-assets.spec.ts new file mode 100644 index 0000000..467d619 --- /dev/null +++ b/tests/e2e/local-assets.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; + +const headers = { 'x-beatapi-workspace-request': '1' }; +const wav = (size: number) => { + const bytes = Buffer.alloc(size); + bytes.write('RIFF'); + bytes.writeUInt32LE(size - 8, 4); + bytes.write('WAVEfmt ', 8); + bytes.writeUInt32LE(16, 16); + bytes.writeUInt16LE(1, 20); + bytes.writeUInt16LE(1, 22); + bytes.writeUInt32LE(44100, 24); + bytes.writeUInt32LE(88200, 28); + bytes.writeUInt16LE(2, 32); + bytes.writeUInt16LE(16, 34); + bytes.write('data', 36); + bytes.writeUInt32LE(size - 44, 40); + return bytes; +}; + +test('local browser uploads accept a full narration beyond the reference clip limit', async ({ request }) => { + const created = await request.post('/api/app/projects', { headers, data: { name: 'Long local narration QA' } }); + expect(created.ok()).toBeTruthy(); + const projectId = (await created.json()).id; + const size = 16 * 1024 * 1024; + const uploaded = await request.post(`/api/app/projects/${projectId}/assets`, { + headers, + multipart: { file: { name: 'full-narration.wav', mimeType: 'audio/wav', buffer: wav(size) } }, + }); + expect(uploaded.status()).toBe(200); + const { asset } = await uploaded.json(); + expect(asset.type).toBe('audio'); + expect(asset.sizeBytes).toBe(size); + const stored = await request.get(asset.publicUrl); + expect(stored.status()).toBe(200); + expect((await stored.body()).length).toBe(size); +}); + +test('project media remains complete beyond 30 items while global recents stay bounded', async ({ request }) => { + const created = await request.post('/api/app/projects', { headers, data: { name: 'Full asset list QA' } }); + const projectId = (await created.json()).id; + const image = await readFile('public/logo.png'); + const video = await readFile('tests/fixtures/tiny-black.mp4'); + const kinds = [ + { type: 'images', name: 'png', mimeType: 'image/png', buffer: image }, + { type: 'videos', name: 'mp4', mimeType: 'video/mp4', buffer: video }, + { type: 'audios', name: 'wav', mimeType: 'audio/wav', buffer: wav(46) }, + ]; + const first = new Map(); + for (const kind of kinds) { + for (let index = 0; index < 32; index += 1) { + const uploaded = await request.post(`/api/app/projects/${projectId}/assets`, { + headers, + multipart: { file: { name: `asset-${index}.${kind.name}`, mimeType: kind.mimeType, buffer: kind.buffer } }, + }); + expect(uploaded.ok()).toBeTruthy(); + const { asset } = await uploaded.json(); + if (index === 0) first.set(kind.type, asset.id); + } + } + const scoped = await (await request.get(`/api/app/recent-assets?projectId=${projectId}`)).json(); + for (const kind of kinds) { + expect(scoped[kind.type]).toHaveLength(32); + expect(scoped[kind.type].some((asset: { id: string }) => asset.id === first.get(kind.type))).toBe(true); + expect(new Set(scoped[kind.type].map((asset: { id: string }) => asset.id)).size).toBe(32); + } + const other = await request.post('/api/app/projects', { headers, data: { name: 'Unrelated project' } }); + const otherId = (await other.json()).id; + const empty = await (await request.get(`/api/app/recent-assets?projectId=${otherId}`)).json(); + expect(empty).toEqual({ images: [], videos: [], audios: [] }); + const global = await (await request.get('/api/app/recent-assets')).json(); + for (const kind of kinds) expect(global[kind.type]).toHaveLength(30); +}); diff --git a/tests/e2e/project-creation.spec.ts b/tests/e2e/project-creation.spec.ts new file mode 100644 index 0000000..753285b --- /dev/null +++ b/tests/e2e/project-creation.spec.ts @@ -0,0 +1,93 @@ +import { expect, test } from '@playwright/test'; + +test.use({ locale: 'en-US' }); + +for (const mode of ['canvas', 'studio'] as const) { + test(`choosing ${mode} creates one project and opens it without a second chooser`, async ({ + page, + }) => { + const creations: string[] = []; + page.on('request', (request) => { + if ( + request.method() === 'POST' && + new URL(request.url()).pathname === '/api/app/projects' + ) { + creations.push(request.postData() ?? ''); + } + }); + await page.goto('/projects'); + await page + .getByRole('button', { name: 'New project', exact: true }) + .first() + .click(); + const choice = mode === 'canvas' ? 'Start in Canvas' : 'Start in Studio'; + await page.getByRole('button', { name: new RegExp(`^${choice}`) }).click(); + await expect(page).toHaveURL(new RegExp(`/${mode}/[^/?]+$`)); + await expect( + page.getByRole('heading', { name: 'Start a new project', exact: true }) + ).toHaveCount(0); + expect(creations).toHaveLength(1); + expect(JSON.parse(creations[0]).workspaceMode).toBe(mode); + }); +} + +test('failed Canvas creation retries in Canvas', async ({ page }) => { + let attempts = 0; + await page.route('**/api/app/projects', async (route) => { + if (route.request().method() !== 'POST') return route.continue(); + expect(route.request().postDataJSON().workspaceMode).toBe('canvas'); + attempts += 1; + if (attempts === 1) { + return route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ error: 'Creation temporarily unavailable' }), + }); + } + return route.continue(); + }); + await page.goto('/projects'); + await page + .getByRole('button', { name: 'New project', exact: true }) + .first() + .click(); + await page.getByRole('button', { name: /^Start in Canvas/ }).click(); + await expect(page.getByRole('alert')).toHaveText( + 'Creation temporarily unavailable' + ); + await page.getByRole('button', { name: 'Try again', exact: true }).click(); + await expect(page).toHaveURL(/\/canvas\/[^/?]+$/); + expect(attempts).toBe(2); +}); + +test('direct Canvas entry requires one explicit create action and retains entry parameters', async ({ + page, +}) => { + let creations = 0; + page.on('request', (request) => { + if ( + request.method() === 'POST' && + new URL(request.url()).pathname === '/api/app/projects' + ) + creations += 1; + }); + await page.goto( + '/canvas?target=image&model=nano-banana-pro&prompt=Entry+test' + ); + const create = page.getByRole('button', { + name: 'Create in Canvas', + exact: true, + }); + await expect(create).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Create in Studio', exact: true }) + ).toHaveCount(0); + expect(creations).toBe(0); + await create.click(); + await expect(page).toHaveURL(/\/canvas\/[^/?]+\?/); + const url = new URL(page.url()); + expect(url.searchParams.get('target')).toBe('image'); + expect(url.searchParams.get('model')).toBe('nano-banana-pro'); + expect(url.searchParams.get('prompt')).toBe('Entry test'); + expect(creations).toBe(1); +}); diff --git a/tests/fixtures/tiny-black.mp4 b/tests/fixtures/tiny-black.mp4 new file mode 100644 index 0000000..a20c060 Binary files /dev/null and b/tests/fixtures/tiny-black.mp4 differ