Skip to content

Commit 9d6dbac

Browse files
committed
fix: upload local canvas references before generate
Same-origin demo assets now follow the just-in-time upload path, and a failed run can reuse files already uploaded for the project.
1 parent 2f86580 commit 9d6dbac

9 files changed

Lines changed: 292 additions & 17 deletions

‎messages/en.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@
291291
"promptTooLong": "Prompt must be shorter than {maxChars} characters.",
292292
"promptRequired": "Please enter a prompt.",
293293
"imageReferencesPending": "Generate the connected upstream image before running this node.",
294+
"localReferenceExpired": "A connected file is only on this page. Re-add it and generate again.",
294295
"imageModelOnlySupportsImageReference": "The current image model only supports image references. This run will continue as text-only generation.",
295296
"videoContinuationUnsupported": "The current video model does not support video continuation. Switch to a model with video input support.",
296297
"statusRequestFailed": "Failed to query task status.",

‎messages/zh.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@
292292
"promptTooLong": "提示词需少于 {maxChars} 个字符。",
293293
"promptRequired": "请输入提示词。",
294294
"imageReferencesPending": "请先生成已连接的上游图片,再运行当前节点。",
295+
"localReferenceExpired": "有参考文件只存在于当前页面,请重新添加后再生成。",
295296
"imageModelOnlySupportsImageReference": "当前图片模型只支持图片参考,本次将按文本生成。",
296297
"videoContinuationUnsupported": "当前视频模型不支持视频续写,请切换到支持视频输入的模型。",
297298
"statusRequestFailed": "查询任务状态失败。",

‎src/core/beatcanvas/generation-controller.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
resolveReferencePayload,
3333
} from '@/core/beatcanvas/canvas-workflows';
3434
import { isDraftBusyStatus } from '@/core/beatcanvas/composer';
35+
import { isLocalWorkspaceMediaUrl } from '@/core/beatcanvas/local-references';
3536

3637
export type StudioJobStatus = CanvasCardStatus;
3738

@@ -225,7 +226,10 @@ export const buildGenerationEffectInput = async ({
225226

226227
const referenceCards = draftCard.referenceCardIds
227228
.map((cardId) => canvasCards[cardId])
228-
.filter((card): card is CanvasCard => Boolean(card?.url))
229+
.filter(
230+
(card): card is CanvasCard =>
231+
Boolean(card?.url) && !isLocalWorkspaceMediaUrl(card.url)
232+
)
229233
.map((card) => toWorkflowReferenceCard(card));
230234

231235
const referencePayload = resolveReferencePayload({
@@ -572,6 +576,18 @@ export const runDraftGeneration = async ({
572576
translate('messages.requestValidationFailed')
573577
);
574578
}
579+
const unresolvedLocalReference = preparedCard.referenceCardIds.some(
580+
(cardId) => {
581+
const card = getCurrentCard(cardId);
582+
return Boolean(card?.url) && isLocalWorkspaceMediaUrl(card.url);
583+
}
584+
);
585+
if (unresolvedLocalReference) {
586+
throw new GenerationFailure(
587+
'storage',
588+
translate('messages.localReferenceExpired')
589+
);
590+
}
575591
input = preparedRequest.input;
576592
}
577593

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
4+
import type { CanvasCard, CanvasDraftCard } from './canvas-types';
5+
import {
6+
getPendingDraftReferenceUploadCount,
7+
isLocalWorkspaceMediaUrl,
8+
promotePendingDraftReferenceUploads,
9+
} from './local-references';
10+
11+
const draft = {
12+
id: 'draft-1',
13+
kind: 'generation',
14+
type: 'image',
15+
referenceCardIds: ['character-card', 'outfit-05', 'bg-05'],
16+
} as CanvasDraftCard;
17+
18+
const localCard = (id: string, url: string): CanvasCard =>
19+
({
20+
id,
21+
kind: 'asset',
22+
type: 'image',
23+
name: id,
24+
url,
25+
referenceCardIds: [],
26+
}) as CanvasCard;
27+
28+
test('counts same-origin demo assets as just-in-time uploads', () => {
29+
assert.equal(
30+
getPendingDraftReferenceUploadCount({
31+
draftCard: draft,
32+
cardsById: {
33+
'character-card': localCard(
34+
'character-card',
35+
'/demo-assets/character.png'
36+
),
37+
'outfit-05': localCard('outfit-05', '/demo-assets/outfit.png'),
38+
'bg-05': localCard('bg-05', '/demo-assets/bg.png'),
39+
},
40+
pendingUploadsByCardId: {},
41+
}),
42+
3
43+
);
44+
assert.equal(isLocalWorkspaceMediaUrl('/demo-assets/character.png'), true);
45+
assert.equal(
46+
isLocalWorkspaceMediaUrl('https://media.beatapi.io/inputs/file.png'),
47+
false
48+
);
49+
});
50+
51+
test('uploads local demo assets through the generation intent', async () => {
52+
const uploaded: string[] = [];
53+
const promotions = await promotePendingDraftReferenceUploads({
54+
draftCard: draft,
55+
cardsById: {
56+
'character-card': localCard(
57+
'character-card',
58+
'/demo-assets/character.png'
59+
),
60+
'outfit-05': localCard('outfit-05', '/demo-assets/outfit.png'),
61+
'bg-05': localCard('bg-05', 'https://media.beatapi.io/keep.png'),
62+
},
63+
pendingUploadsByCardId: {},
64+
projectId: 'project-1',
65+
generationIntentToken: 'intent-1',
66+
fetchImpl: async (input) =>
67+
new Response(new Uint8Array([1, 2, 3]), {
68+
status: 200,
69+
headers: { 'content-type': 'image/png' },
70+
}),
71+
uploadFileFromBrowserImpl: async (file) => {
72+
uploaded.push(file.name);
73+
return { url: `https://media.beatapi.io/${file.name}`, key: file.name };
74+
},
75+
});
76+
77+
assert.deepEqual(uploaded, ['character.png', 'outfit.png']);
78+
assert.equal(promotions.length, 2);
79+
assert.equal(
80+
promotions[0]?.uploadResult.url,
81+
'https://media.beatapi.io/character.png'
82+
);
83+
});

‎src/core/beatcanvas/local-references.ts‎

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,42 @@ export type PendingLocalReferenceUpload = {
99
};
1010

1111
type UploadFileFromBrowserImpl = typeof uploadFileFromBrowser;
12+
type FetchImpl = typeof fetch;
13+
14+
export const isTransientCanvasUrl = (url: string | null | undefined) =>
15+
typeof url === 'string' &&
16+
(url.startsWith('blob:') || url.startsWith('data:'));
17+
18+
export const isLocalWorkspaceMediaUrl = (url: string | null | undefined) => {
19+
if (!url) return false;
20+
if (isTransientCanvasUrl(url)) return true;
21+
if (url.startsWith('/') && !url.startsWith('//')) return true;
22+
try {
23+
const parsed = new URL(url);
24+
const origin =
25+
typeof window !== 'undefined' && window.location?.origin
26+
? window.location.origin
27+
: '';
28+
if (origin) return parsed.origin === origin;
29+
return ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
30+
} catch {
31+
return false;
32+
}
33+
};
34+
35+
const needsJustInTimeUpload = ({
36+
card,
37+
pendingUpload,
38+
}: {
39+
card: CanvasCard | undefined;
40+
pendingUpload?: PendingLocalReferenceUpload;
41+
}) => {
42+
if (!card || card.kind === 'generation') return false;
43+
if (pendingUpload && (!card.url || isTransientCanvasUrl(card.url))) {
44+
return true;
45+
}
46+
return Boolean(card.url && isLocalWorkspaceMediaUrl(card.url));
47+
};
1248

1349
export const getPendingDraftReferenceUploadCount = ({
1450
draftCard,
@@ -24,22 +60,34 @@ export const getPendingDraftReferenceUploadCount = ({
2460
for (const cardId of draftCard.referenceCardIds) {
2561
if (seenCardIds.has(cardId)) continue;
2662
seenCardIds.add(cardId);
27-
const card = cardsById[cardId];
2863
if (
29-
pendingUploadsByCardId[cardId] &&
30-
card &&
31-
card.kind !== 'generation' &&
32-
(!card.url || isTransientCanvasUrl(card.url))
64+
needsJustInTimeUpload({
65+
card: cardsById[cardId],
66+
pendingUpload: pendingUploadsByCardId[cardId],
67+
})
3368
) {
3469
count += 1;
3570
}
3671
}
3772
return count;
3873
};
3974

40-
export const isTransientCanvasUrl = (url: string | null | undefined) =>
41-
typeof url === 'string' &&
42-
(url.startsWith('blob:') || url.startsWith('data:'));
75+
const fileFromWorkspaceUrl = async (
76+
url: string,
77+
fetchImpl: FetchImpl
78+
): Promise<File> => {
79+
const response = await fetchImpl(url);
80+
if (!response.ok) {
81+
throw new Error('Failed to read a local canvas reference');
82+
}
83+
const blob = await response.blob();
84+
const filename = decodeURIComponent(
85+
url.split('/').pop()?.split('?')[0] || 'reference'
86+
);
87+
return new File([blob], filename, {
88+
type: blob.type || 'application/octet-stream',
89+
});
90+
};
4391

4492
export const promotePendingDraftReferenceUploads = async ({
4593
draftCard,
@@ -48,13 +96,15 @@ export const promotePendingDraftReferenceUploads = async ({
4896
projectId,
4997
generationIntentToken,
5098
uploadFileFromBrowserImpl = uploadFileFromBrowser,
99+
fetchImpl = fetch,
51100
}: {
52101
draftCard: CanvasDraftCard;
53102
cardsById: Record<string, CanvasCard>;
54103
pendingUploadsByCardId: Record<string, PendingLocalReferenceUpload>;
55104
projectId: string;
56105
generationIntentToken: string;
57106
uploadFileFromBrowserImpl?: UploadFileFromBrowserImpl;
107+
fetchImpl?: FetchImpl;
58108
}): Promise<
59109
Array<{
60110
cardId: string;
@@ -77,16 +127,22 @@ export const promotePendingDraftReferenceUploads = async ({
77127

78128
const pendingUpload = pendingUploadsByCardId[cardId];
79129
const card = cardsById[cardId];
80-
if (!pendingUpload || !card || card.kind === 'generation') {
130+
if (
131+
!needsJustInTimeUpload({
132+
card,
133+
pendingUpload,
134+
})
135+
) {
81136
continue;
82137
}
83138

84-
if (card.url && !isTransientCanvasUrl(card.url)) {
85-
continue;
86-
}
139+
const file =
140+
pendingUpload && (!card.url || isTransientCanvasUrl(card.url))
141+
? pendingUpload.file
142+
: await fileFromWorkspaceUrl(card.url as string, fetchImpl);
87143

88144
const uploadResult = await uploadFileFromBrowserImpl(
89-
pendingUpload.file,
145+
file,
90146
'beatcanvas/uploads',
91147
{
92148
projectId,
@@ -96,7 +152,7 @@ export const promotePendingDraftReferenceUploads = async ({
96152

97153
promotions.push({
98154
cardId,
99-
objectUrl: pendingUpload.objectUrl,
155+
objectUrl: pendingUpload?.objectUrl || card.url || '',
100156
uploadResult,
101157
});
102158
}

‎src/core/effects/generation-upload-intent.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,65 @@ test('zero-upload intents remain valid once and expired intents are rejected', a
228228
}
229229
});
230230

231+
test('zero-upload retries can reuse previously uploaded project files', async () => {
232+
const { client, db } = await createTestDb();
233+
try {
234+
const now = new Date(1_000);
235+
const firstIntentId = await issueGenerationUploadIntent({
236+
projectId: 'project-1',
237+
effectId: 301,
238+
expectedUploadCount: 1,
239+
now,
240+
dbClient: db,
241+
});
242+
const slotId = await claimGenerationUploadSlot({
243+
intentId: firstIntentId,
244+
projectId: 'project-1',
245+
now: new Date(2_000),
246+
dbClient: db,
247+
});
248+
assert.ok(slotId);
249+
assert.equal(
250+
await completeGenerationUploadSlot({
251+
intentId: firstIntentId,
252+
upload: {
253+
slotId,
254+
provider: 'beatapi',
255+
bucket: 'beatapi',
256+
key: 'inputs/retry.png',
257+
url: 'https://media.beatapi.io/inputs/retry.png',
258+
filename: 'retry.png',
259+
mimeType: 'image/png',
260+
sizeBytes: 123,
261+
},
262+
now: new Date(3_000),
263+
dbClient: db,
264+
}),
265+
true
266+
);
267+
268+
const retryIntentId = await issueGenerationUploadIntent({
269+
projectId: 'project-1',
270+
effectId: 302,
271+
expectedUploadCount: 0,
272+
now: new Date(4_000),
273+
dbClient: db,
274+
});
275+
assert.ok(
276+
await consumeGenerationUploadIntent({
277+
intentId: retryIntentId,
278+
projectId: 'project-1',
279+
effectId: 302,
280+
referencedUrls: ['https://media.beatapi.io/inputs/retry.png'],
281+
now: new Date(5_000),
282+
dbClient: db,
283+
})
284+
);
285+
} finally {
286+
client.close();
287+
}
288+
});
289+
231290
test('limits active upload authorizations per project', async () => {
232291
const { client, db } = await createTestDb();
233292
try {

0 commit comments

Comments
 (0)