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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,11 @@ Studio / Canvas / Assets
-> just-in-time provider upload of the durable local reference
-> BeatAPI image or video task API
-> provider status polling
-> generated media copied into the local project asset directory
-> local generation history and asset index
```

The API key and storage credentials remain server-side. Project state and history live in the local SQLite database. In local SQLite mode, imported image and video files are copied immediately into the project-owned `data/project-assets/<project-id>/` directory and indexed in SQLite before a card is added to the Canvas. Video analysis uploads MP4/MOV input through BeatAPI's file endpoint and stores the resulting text in the same project generation history. Generated files remain at the public URLs returned by the provider and are indexed locally instead of being copied again.
The API key and storage credentials remain server-side. Project state and history live in the local SQLite database. In local SQLite mode, imported image and video files are copied immediately into the project-owned `data/project-assets/<project-id>/` directory and indexed in SQLite before a card is added to the Canvas. Video analysis uploads MP4/MOV input through BeatAPI's file endpoint and stores the resulting text in the same project generation history. Generated images, videos, and video covers are downloaded into the same project-owned directory before the Canvas receives their URLs; provider URLs remain metadata only.

Canvas state is saved as a complete project snapshot after changes, checked again every five seconds while dirty, and flushed when the page is hidden, refreshed, or closed. A populated snapshot cannot be replaced by an unconfirmed empty snapshot.

Expand All @@ -72,6 +73,7 @@ The canonical catalog lives in `src/core/effects/effect-registry.ts`. The curren
| `pnpm typecheck` | Check TypeScript contracts |
| `pnpm test` | Run unit and contract tests |
| `pnpm i18n:check` | Validate English and Chinese messages |
| `pnpm media:localize` | Copy provider-hosted media in existing project snapshots into local project storage |
| `pnpm build` | Build the production app |
| `pnpm db:push` | Apply the schema during local development |
| `pnpm db:generate` | Generate a reviewable D1/SQLite migration |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"test": "sh -c 'rg --files src -g \"*.test.ts\" -g \"*.test.tsx\" | sort | xargs node --import tsx --test'",
"typecheck": "tsc --noEmit",
"i18n:check": "node scripts/check-i18n.mjs",
"media:localize": "tsx scripts/with-env.ts pnpm exec tsx scripts/localize-project-media.ts",
"prebuild": "node scripts/db-setup.mjs",
"build": "tsx scripts/prepare-paraglide.ts && vite build && node scripts/sanitize-output.mjs",
"start": "tsx scripts/with-env.ts node .output/server/index.mjs",
Expand Down
138 changes: 138 additions & 0 deletions scripts/localize-project-media.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { isOfficialBeatApiMediaUrl } from '@/core/effects/beatapi-media-url';
import {
createLocalProviderAssetId,
LOCAL_MEDIA_DOWNLOAD_TIMEOUT_MS,
MAX_LOCAL_IMAGE_ASSET_BYTES,
MAX_LOCAL_VIDEO_ASSET_BYTES,
} from '@/core/effects/output-storage';
import {
LOCAL_PROJECT_ASSET_BUCKET,
LOCAL_PROJECT_ASSET_PROVIDER,
persistLocalProjectAsset,
} from '@/core/projects/local-project-assets';
import {
loadProjectWithLatestSnapshot,
loadProjects,
saveProjectSnapshot,
} from '@/core/projects/projects';
import {
linkProjectAsset,
recordUserAsset,
} from '@/core/workspace-lib/assets/user-assets';
import { readResponseBodyWithLimit } from '@/lib/response-body-limit';

const filenameFromUrl = (url: string, type: 'image' | 'video') => {
try {
const filename = decodeURIComponent(
new URL(url).pathname.split('/').pop() || ''
);
if (filename) return filename;
} catch {
// Fall through to a stable filename.
}
return type === 'video' ? 'migrated-video.mp4' : 'migrated-image.png';
};

const localizeProject = async (projectId: string) => {
const state = await loadProjectWithLatestSnapshot({ projectId });
if (!state) return { cards: 0, files: 0 };

const cardsWithRemoteMedia = state.snapshot.cards.filter(
(card) => Boolean(card.url && isOfficialBeatApiMediaUrl(card.url))
);
if (cardsWithRemoteMedia.length === 0) return { cards: 0, files: 0 };

const localizedByProviderUrl = new Map<
string,
{ assetId: string; publicUrl: string }
>();

for (const card of cardsWithRemoteMedia) {
const providerUrl = card.url as string;
if (localizedByProviderUrl.has(providerUrl)) continue;

const response = await fetch(providerUrl, {
signal: AbortSignal.timeout(LOCAL_MEDIA_DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`Download failed (${response.status}): ${providerUrl}`);
}
const maxBytes =
card.type === 'video'
? MAX_LOCAL_VIDEO_ASSET_BYTES
: MAX_LOCAL_IMAGE_ASSET_BYTES;
const bytes = await readResponseBodyWithLimit(response, maxBytes);
const responseMimeType =
response.headers.get('content-type')?.split(';')[0]?.trim() || '';
const mimeType = responseMimeType.startsWith(`${card.type}/`)
? responseMimeType
: card.type === 'video'
? 'video/mp4'
: 'image/png';
const persisted = await persistLocalProjectAsset({
projectId,
assetId: createLocalProviderAssetId(projectId, providerUrl),
filename: filenameFromUrl(providerUrl, card.type),
mimeType,
bytes,
});
const assetId = await recordUserAsset({
id: persisted.assetId,
type: card.type,
source: 'provider',
storageProvider: LOCAL_PROJECT_ASSET_PROVIDER,
bucket: LOCAL_PROJECT_ASSET_BUCKET,
objectKey: persisted.objectKey,
publicUrl: persisted.publicUrl,
filename: persisted.filename,
mimeType,
sizeBytes: persisted.sizeBytes,
sha256: persisted.sha256,
originProjectId: projectId,
metadata: { migratedFromProviderUrl: providerUrl },
});
await linkProjectAsset({
projectId,
assetId,
role: card.kind === 'output' ? 'generated' : 'reference',
metadata: { migratedFromProviderUrl: providerUrl },
});
localizedByProviderUrl.set(providerUrl, {
assetId,
publicUrl: persisted.publicUrl,
});
console.log(`localized ${card.type}: ${persisted.filename}`);
}

const nextCards = state.snapshot.cards.map((card) => {
const localized = card.url
? localizedByProviderUrl.get(card.url)
: undefined;
return localized
? { ...card, url: localized.publicUrl, assetId: localized.assetId }
: card;
});
await saveProjectSnapshot({
projectId,
document: { ...state.snapshot, cards: nextCards },
baseVersion: state.snapshotVersion,
});

return {
cards: cardsWithRemoteMedia.length,
files: localizedByProviderUrl.size,
};
};

const projects = await loadProjects({ limit: 1_000 });
let localizedCards = 0;
let localizedFiles = 0;
for (const currentProject of projects) {
const result = await localizeProject(currentProject.id);
localizedCards += result.cards;
localizedFiles += result.files;
}

console.log(
`done: ${localizedFiles} local files now back ${localizedCards} canvas cards`
);
1 change: 1 addition & 0 deletions src/components/beatcanvas/react-flow/react-flow-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,7 @@ function BeatCanvasReactFlowCanvas({
zoomOnPinch
nodesDraggable={interactionMode === 'select'}
nodesConnectable={false}
onlyRenderVisibleElements
edgesFocusable
snapToGrid={isSnapToGridEnabled}
snapGrid={[24, 24]}
Expand Down
13 changes: 10 additions & 3 deletions src/components/beatcanvas/use-beatcanvas-generation-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function useBeatCanvasGenerationRuntime({
promotePendingUploadsForDraft: (
draftId: string,
generationIntentToken: string
) => Promise<void>;
) => Promise<Record<string, string>>;
createGenerationOutput: (params: {
draftCard: CanvasDraftCard;
name: string;
Expand Down Expand Up @@ -132,10 +132,14 @@ export function useBeatCanvasGenerationRuntime({
);

const buildEffectInput = useCallback(
(draftCard: CanvasDraftCard) =>
(
draftCard: CanvasDraftCard,
referenceUrlOverrides?: Record<string, string>
) =>
buildGenerationEffectInput({
draftCard,
canvasCards: canvasCardsRef.current,
referenceUrlOverrides,
imageModels,
videoModels,
metadataMap,
Expand Down Expand Up @@ -218,7 +222,10 @@ export function useBeatCanvasGenerationRuntime({
}
setStatusMessage(studioT('messages.preparingAssets'));
try {
await promotePendingUploadsForDraft(draftId, uploadIntentToken);
return await promotePendingUploadsForDraft(
draftId,
uploadIntentToken
);
} catch (error) {
throw new Error(
getDraftUploadFailureMessage({
Expand Down
12 changes: 5 additions & 7 deletions src/components/beatcanvas/use-beatcanvas-upload-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,7 @@ export function useBeatCanvasUploadActions({
async (draftId: string, generationIntentToken: string) => {
const draftCard = canvasCardsRef.current[draftId];
if (!isCanvasDraftCard(draftCard)) {
return;
return {};
}

const promotions = await promotePendingDraftReferenceUploads({
Expand All @@ -580,19 +580,17 @@ export function useBeatCanvasUploadActions({
generationIntentToken,
});

const providerUrlsByCardId: Record<string, string> = {};
for (const promotion of promotions) {
updateCanvasCard(promotion.cardId, (current) => ({
...current,
name: current.name || promotion.uploadResult.key,
url: promotion.uploadResult.url,
}));
providerUrlsByCardId[promotion.cardId] = promotion.uploadResult.url;
delete pendingUploadsRef.current[promotion.cardId];
if (promotion.objectUrl.startsWith('blob:')) {
URL.revokeObjectURL(promotion.objectUrl);
}
}
return providerUrlsByCardId;
},
[canvasCardsRef, projectId, updateCanvasCard]
[canvasCardsRef, projectId]
);

const getPendingUploadCountForDraft = useCallback(
Expand Down
36 changes: 33 additions & 3 deletions src/core/beatcanvas/generation-controller-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';

import type {
CanvasCard,
CanvasDraftCard,
CanvasOutputCard,
} from './canvas-types';
Expand Down Expand Up @@ -30,6 +31,15 @@ const makeDraft = (): CanvasDraftCard => ({

test('keeps the generation configuration and completes a separate output card', async () => {
let draft = makeDraft();
const localAsset: CanvasCard = {
...makeDraft(),
id: 'asset:1',
kind: 'asset',
name: 'Local reference',
url: '/api/app/projects/project-1/assets/asset-1',
prompt: '',
referenceCardIds: [],
};
const outputPatches: Array<Partial<CanvasOutputCard>> = [];
let completedOutputId: string | null = null;
const callOrder: string[] = [];
Expand All @@ -38,13 +48,26 @@ test('keeps the generation configuration and completes a separate output card',
const completed = await runDraftGeneration({
draftId: draft.id,
projectId: 'project:1',
getCurrentCard: () => draft,
buildEffectInput: async () => {
getCurrentCard: (cardId) =>
cardId === localAsset.id ? localAsset : draft,
buildEffectInput: async (_currentDraft, referenceUrlOverrides) => {
buildCount += 1;
callOrder.push(`build:${buildCount}`);
if (buildCount === 1) {
assert.equal(referenceUrlOverrides, undefined);
} else {
assert.deepEqual(referenceUrlOverrides, {
'asset:1': 'https://media.beatapi.io/inputs/asset-1.png',
});
}
return ({
effectId: 1,
input: { prompt: draft.prompt },
input: {
prompt: draft.prompt,
...(referenceUrlOverrides
? { image_urls: [referenceUrlOverrides['asset:1']] }
: {}),
},
model: { name: 'Test model' },
}) as never;
},
Expand Down Expand Up @@ -77,9 +100,16 @@ test('keeps the generation configuration and completes a separate output card',
prepareAfterPrecheck: async ({ uploadIntentToken }) => {
assert.equal(uploadIntentToken, 'signed-upload-intent');
callOrder.push('upload');
return {
'asset:1': 'https://media.beatapi.io/inputs/asset-1.png',
};
},
generateEffectImpl: async (payload) => {
assert.equal(payload.generationIntentToken, 'signed-upload-intent');
assert.deepEqual(payload.input.image_urls, [
'https://media.beatapi.io/inputs/asset-1.png',
]);
assert.equal(localAsset.url, '/api/app/projects/project-1/assets/asset-1');
callOrder.push('generate');
return ({
ok: true,
Expand Down
Loading
Loading