Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/PRODUCT_PLAN_AND_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ Codex / Claude Code / Other Agent
- 界面支持英语、中文和日语,并可在主页、项目列表及工作区顶栏切换;英文使用无前缀路径,中文和日语分别使用 `/zh` 与 `/ja`。
- Canvas Snapshot 自动保存、页面隐藏时刷新和版本冲突保护。
- 本地上传图片、视频、音频后立即复制到项目目录。
- 本地项目完整配音可通过浏览器和 MCP 导入,音频上限 128 MiB;模型参考音频仍遵守独立的 15 MiB 限制。
- 项目素材列表按项目成员关系返回完整媒体,不再因全局最近 30 条的预览上限隐藏早期素材;全局最近素材保留每类 30 条上限。
- 生成图片、视频和封面在展示前本地化。
- 项目 Assets 和生成历史索引。
- BeatAPI 与存储凭证保留在服务端。
Expand Down
14 changes: 7 additions & 7 deletions src/core/effects/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand All @@ -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,
};
}
Expand Down
30 changes: 29 additions & 1 deletion src/core/projects/import-local-asset.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
7 changes: 6 additions & 1 deletion src/core/projects/import-local-asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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.');
}
Expand Down
1 change: 1 addition & 0 deletions src/core/workspace-lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
6 changes: 3 additions & 3 deletions src/routes/api/app/projects/$projectId/assets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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' },
Expand Down
3 changes: 0 additions & 3 deletions src/routes/api/app/recent-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ async function GET({ request }: { request: Request }) {
)
)
.orderBy(desc(userAsset.createdAt))
.limit(30)
: db
.select(imageFields)
.from(userAsset)
Expand Down Expand Up @@ -77,7 +76,6 @@ async function GET({ request }: { request: Request }) {
)
)
.orderBy(desc(userAsset.createdAt))
.limit(30)
: db
.select(videoFields)
.from(userAsset)
Expand Down Expand Up @@ -106,7 +104,6 @@ async function GET({ request }: { request: Request }) {
)
)
.orderBy(desc(userAsset.createdAt))
.limit(30)
: db
.select(videoFields)
.from(userAsset)
Expand Down
74 changes: 74 additions & 0 deletions tests/e2e/local-assets.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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);
});
93 changes: 93 additions & 0 deletions tests/e2e/project-creation.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Binary file added tests/fixtures/tiny-black.mp4
Binary file not shown.