diff --git a/apps/api/src/opendirector_api/creation.py b/apps/api/src/opendirector_api/creation.py index 03e3419a..29bb54ea 100644 --- a/apps/api/src/opendirector_api/creation.py +++ b/apps/api/src/opendirector_api/creation.py @@ -20,7 +20,7 @@ from opendirector.domain.creation.project import ProjectSnapshot, ViewState from opendirector.domain.projects import ProjectError from opendirector.infrastructure.creation_projects import CreationProjects -from opendirector_api.models import Session +from opendirector_api.models import AssetSource, Session def creation_routes(projects: CreationProjects, generation=None): @@ -194,6 +194,11 @@ async def upload(project_id: UUID, handle_id: UUID, request: Request, # Also runs on ClientDisconnect and cancelled request bodies. writer.abort() + @router.put('/projects/{project_id}/uploads/{handle_id}/copy', response_model=UploadInfo) + def copy_upload(project_id: UUID, handle_id: UUID, source: AssetSource, x_opendirector_session: UUID = Header()): + return assets.stage_copy(project_id, handle_id, str(x_opendirector_session), source.projectId, source.assetId, + source.fingerprint) + @router.get('/projects/{project_id}/uploads/{handle_id}', response_model=UploadInfo) def upload_info(project_id: UUID, handle_id: UUID, x_opendirector_session: UUID = Header()): return assets.uploads.info(project_id, handle_id, str(x_opendirector_session)) diff --git a/apps/api/src/opendirector_api/models.py b/apps/api/src/opendirector_api/models.py index 09eb4461..17248dfc 100644 --- a/apps/api/src/opendirector_api/models.py +++ b/apps/api/src/opendirector_api/models.py @@ -15,6 +15,13 @@ class Session(Model): projectId: UUID +class AssetSource(Model): + """An asset in another creation project, copied on the server.""" + projectId: UUID + assetId: UUID + fingerprint: str | None = Field(default=None, max_length=128, description='Expected source content; a different one is refused.') + + class ModelConnection(Model): connectionType: Literal['api', 'self_hosted'] = 'api' protocol: Literal['openai', 'volcengine', 'minimax_video_v2', 'vllm_omni', 'vllm_omni_segmentation', 'vllm_omni_music', 'vllm_omni_depth', 'vllm_omni_score'] | None = None diff --git a/packages/core/src/opendirector/application/creation_assets.py b/packages/core/src/opendirector/application/creation_assets.py index e1e2319c..25847db9 100644 --- a/packages/core/src/opendirector/application/creation_assets.py +++ b/packages/core/src/opendirector/application/creation_assets.py @@ -29,6 +29,26 @@ def __init__(self, projects): self._verification_lock = Lock() self._thumbnails = OrderedDict() + def stage_copy(self, project_id, handle, session, source_project_id, source_asset_id, fingerprint=None): + """Stage another project's verified asset as an upload; its bytes never leave the server. + + Registration still goes through asset.import, so the copy is an ordinary + upload in the target project and keeps none of the source's provenance. + A caller that already holds the bytes passes their fingerprint, so a + changed source is refused instead of copied. + """ + stream, asset = self.open_media(source_project_id, source_asset_id) + with stream: + if fingerprint is not None and fingerprint != asset.fingerprint: + raise ProjectError('来源素材已变化。', 409) + writer = self.uploads.begin(project_id, handle, session, asset.name, expected_kind=asset.kind) + try: + while chunk := stream.read(1024 * 1024): + writer.write(chunk) + return writer.finish() + finally: + writer.abort() + def reference_available(self, project_id, asset_id, *, snapshot=None): stream, asset = self.open_media(project_id, asset_id, snapshot=snapshot) with stream: diff --git a/src/backend/creationClient.ts b/src/backend/creationClient.ts index 3f1b42f0..73748f44 100644 --- a/src/backend/creationClient.ts +++ b/src/backend/creationClient.ts @@ -401,6 +401,24 @@ export class CreationClient { }, ); if (!response.ok) throw new Error("上传素材失败,请检查文件格式和大小。"); + return this.registerUpload(handle, name, kind); + } + /** Copies another project's asset on the server; no bytes pass through the page. */ + async copyAsset( + source: { projectId: string; assetId: string; fingerprint?: string }, + handle: string = crypto.randomUUID(), + ) { + const info = await storageRequest<{ name: string; kind: Asset["kind"] }>( + `${creationPath(this.id)}/uploads/${handle}/copy`, + json("PUT", source), + ); + return this.registerUpload(handle, info.name, info.kind); + } + private async registerUpload( + handle: string, + name: string, + kind: Asset["kind"], + ) { const receipt = await this.command("asset.import", { uploadHandle: handle, name, @@ -414,6 +432,17 @@ export class CreationClient { if (!asset) throw new Error("素材登记未返回稳定身份。"); return assetReference(asset); } + /** Previews an asset from bytes the page already holds instead of reading them back. */ + adoptMedia(assetId: string, blob: Blob) { + let pending = this.media.get(assetId); + if (!pending) { + const url = URL.createObjectURL(blob); + this.sourceAssets.set(url, assetId); + pending = Promise.resolve(url); + this.media.set(assetId, pending); + } + return pending; + } async writeText(name: string, text: string, previous?: AssetRef) { const receipt = await this.command( "asset.writeText", diff --git a/src/canvas/CanvasWorkspace.tsx b/src/canvas/CanvasWorkspace.tsx index 806067eb..2c17a988 100644 --- a/src/canvas/CanvasWorkspace.tsx +++ b/src/canvas/CanvasWorkspace.tsx @@ -2273,15 +2273,25 @@ function Workspace() { setSelectedEdges([]); setMenu(null); }; + const captureMedia = (fragment: CanvasFragment) => { + const projectId = historyRef.current.present.id; + const client = creationClient(projectId); + return captureClipboardMedia(projectId, fragment, { + resolve: (src, assetId) => { + // A just-imported node may show its upload before it records the asset. + const asset = assetId + ? client.snapshot.assets.find((asset) => asset.id === assetId) + : client.assetForSource(src); + return asset && { assetId: asset.id, fingerprint: asset.fingerprint }; + }, + }); + }; const copy = () => { clipboard.current = copySelection( historyRef.current.present, selectedRef.current, ); - clipboardMedia.current = captureClipboardMedia( - historyRef.current.present.id, - clipboard.current, - ); + clipboardMedia.current = captureMedia(clipboard.current); pasteCount.current = 0; notify(`已复制 ${clipboard.current.nodes.length} 个节点`); }; @@ -2299,10 +2309,7 @@ function Workspace() { const fragment = result.fragment; change(() => result.document); clipboard.current = fragment; - clipboardMedia.current = captureClipboardMedia( - historyRef.current.present.id, - fragment, - ); + clipboardMedia.current = captureMedia(fragment); pasteCount.current = 0; setSelected([]); setMenu(null); @@ -2333,16 +2340,19 @@ function Workspace() { fragment, media, projectId, - async (blob, title, kind) => { + async (blob, title, kind, source) => { const client = creationClient(projectId); - const ref = await client.importBlob( - blob, - withMediaSuffix(title, blob.type), - kind, - ); + const upload = () => + client.importBlob(blob, withMediaSuffix(title, blob.type), kind); + // Saved media is copied on the server. Unsaved media, or a source + // that changed or is gone, is uploaded from the bytes captured at copy. + const ref = source + ? await client.copyAsset(source).catch(upload) + : await upload(); + // The page already holds these bytes; reading them back is not needed. return { assetId: ref.assetId, - src: await client.mediaUrl(ref.assetId), + src: await client.adoptMedia(ref.assetId, blob), }; }, ); diff --git a/src/canvas/clipboardMedia.ts b/src/canvas/clipboardMedia.ts index 8d40105a..234d99dd 100644 --- a/src/canvas/clipboardMedia.ts +++ b/src/canvas/clipboardMedia.ts @@ -11,10 +11,12 @@ const KINDS = new Set([ ]); /** Media copied with a fragment. Switching projects revokes the source's object - * URLs, so the bytes are read while the copy is made. */ + * URLs, so the bytes are read while the copy is made; saved assets also keep + * their source identity, so a paste can copy them on the server. */ export interface ClipboardMedia { projectId: string; blobs: Map>; + sources: Map; /** Registered copies per target project, so repeated pastes and retries reuse them. */ registered: Map>; } @@ -24,15 +26,34 @@ export interface RegisteredMedia { src: string; } +/** A saved asset in the source project, with the content the copy captured. */ +export interface MediaSource { + projectId: string; + assetId: string; + fingerprint: string; +} + export type RegisterMedia = ( blob: Blob, title: string, kind: CanvasResource["kind"], + source?: MediaSource, ) => Promise; const isTemporary = (value: unknown): value is string => typeof value === "string" && /^(blob|data):/i.test(value); +/** The saved asset a media field shows: a node's own source or a nested resource's. */ +function savedAssetId( + node: CanvasFragment["nodes"][number], + record: Record, + key: string, +) { + if (key !== "src") return undefined; + const id = record === node.data ? node.data.backendAssetId : record.assetId; + return typeof id === "string" ? id : undefined; +} + function visitMedia( value: unknown, found: (record: Record, key: string, src: string) => void, @@ -55,17 +76,34 @@ async function readBlob(src: string): Promise { export function captureClipboardMedia( projectId: string, fragment: CanvasFragment, - read: (src: string) => Promise = readBlob, + { + read = readBlob, + resolve = () => undefined, + }: { + read?: (src: string) => Promise; + /** The saved asset behind a field, from its recorded ID or its preview URL. */ + resolve?: ( + src: string, + assetId: string | undefined, + ) => Omit | undefined; + } = {}, ): ClipboardMedia { const blobs = new Map>(); - visitMedia(fragment.nodes, (_record, _key, src) => { - if (blobs.has(src)) return; - const pending = read(src); - // A failed read is reported by the paste that needs it. - pending.catch(() => {}); - blobs.set(src, pending); - }); - return { projectId, blobs, registered: new Map() }; + const sources = new Map(); + for (const node of fragment.nodes) + visitMedia(node, (record, key, src) => { + const saved = + key === "src" + ? resolve(src, savedAssetId(node, record, key)) + : undefined; + if (saved && !sources.has(src)) sources.set(src, { projectId, ...saved }); + if (blobs.has(src)) return; + const pending = read(src); + // A failed read is reported by the paste that needs it. + pending.catch(() => {}); + blobs.set(src, pending); + }); + return { projectId, blobs, sources, registered: new Map() }; } /** @@ -105,7 +143,12 @@ export async function transferClipboardMedia( if (!pending) { pending = blob .then((bytes) => - register(bytes, title, kind ?? mediaKind(bytes.type)), + register( + bytes, + title, + kind ?? mediaKind(bytes.type), + media.sources.get(src), + ), ) .catch((error) => { media.registered.delete(cacheKey); diff --git a/tests/browser/cross_canvas_paste.mjs b/tests/browser/cross_canvas_paste.mjs index 966109d7..6ee02e3e 100644 --- a/tests/browser/cross_canvas_paste.mjs +++ b/tests/browser/cross_canvas_paste.mjs @@ -76,9 +76,20 @@ try { await page.locator(".cw-project-name").filter({ hasText: target.name }).waitFor(); await saved(); await page.locator(".cw-workspace").focus(); + // Saved media is copied on the server: no bytes go up or come back down. + const transfers = []; + const watch = (request) => { + const path = new URL(request.url()).pathname; + if (/\/uploads\/[^/]+(\/copy)?$/.test(path) && request.method() === "PUT" || path.endsWith("/content")) + transfers.push(`${request.method()} ${path.replace(/^.*\/projects\/[^/]+/, "")}`); + }; + page.on("request", watch); await page.keyboard.press("Control+v"); await nodes.nth(1).waitFor(); await loadedImages(2); + page.off("request", watch); + assert.deepEqual(transfers.map((entry) => entry.replace(/[0-9a-f-]{36}/g, "")).sort(), + ["PUT /uploads//copy", "PUT /uploads//copy"]); const pasted = await until(async () => { const snapshot = await target.state(); return snapshot.canvasItems.filter((item) => item.visible).length === 2 && snapshot; diff --git a/tests/clipboardMedia.test.ts b/tests/clipboardMedia.test.ts index 5aba6371..c883b913 100644 --- a/tests/clipboardMedia.test.ts +++ b/tests/clipboardMedia.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { captureClipboardMedia, transferClipboardMedia, + type MediaSource, type RegisteredMedia, } from "../src/canvas/clipboardMedia.ts"; import { createNode } from "../src/canvas/model.ts"; @@ -173,12 +174,65 @@ test("repeated pastes reuse registrations and a failed one can be retried", asyn test("an unreadable copy fails the paste instead of inserting broken nodes", async () => { const { fragment } = fixture(); - const media = captureClipboardMedia("project-a", fragment, async (src) => { - if (src === fragment.nodes[1]!.data.src) throw new Error("素材读取失败。"); - return new Blob(["x"], { type: "image/png" }); + const media = captureClipboardMedia("project-a", fragment, { + read: async (src) => { + if (src === fragment.nodes[1]!.data.src) + throw new Error("素材读取失败。"); + return new Blob(["x"], { type: "image/png" }); + }, }); await assert.rejects( transferClipboardMedia(fragment, media, "project-b", registrar().register), /素材读取失败/, ); }); + +test("saved source assets are offered for a server-side copy", async () => { + const { fragment, clip } = fixture(); + // A just-imported node shows its upload before it records the asset. + delete clip.data.backendAssetId; + const byId = new Map([["source-image", "sha256:image"]]); + const bySource = new Map([[clip.data.src!, "source-video"]]); + const resolved: [string, string | undefined][] = []; + const media = captureClipboardMedia("project-a", fragment, { + resolve: (src, assetId) => { + resolved.push([src, assetId]); + const id = assetId ?? bySource.get(src); + if (id === "source-image") + return { assetId: id, fingerprint: byId.get(id)! }; + if (id === "source-video") + return { assetId: id, fingerprint: "sha256:video" }; + // The portrait is unknown to the project, so it can only be uploaded. + return undefined; + }, + }); + assert.deepEqual( + resolved.map(([, assetId]) => assetId), + ["source-image", undefined, "source-portrait"], + ); + const sources: (MediaSource | undefined)[] = []; + const pasted = await transferClipboardMedia( + fragment, + media, + "project-b", + async (_blob, title, _kind, source) => { + sources.push(source); + return { assetId: `copy-of-${title}`, src: `blob:target/${title}` }; + }, + ); + assert.deepEqual(sources, [ + { + projectId: "project-a", + assetId: "source-image", + fingerprint: "sha256:image", + }, + { + projectId: "project-a", + assetId: "source-video", + fingerprint: "sha256:video", + }, + undefined, + ]); + assert.equal(pasted.nodes[1]!.data.backendAssetId, "copy-of-镜头"); + assert.equal(pasted.nodes[2]!.data.subject!.main!.assetId, "copy-of-正脸"); +}); diff --git a/tests/test_creation_assets.py b/tests/test_creation_assets.py index fb77c424..9a2a6a4b 100644 --- a/tests/test_creation_assets.py +++ b/tests/test_creation_assets.py @@ -461,6 +461,55 @@ def test_public_sources_and_file_paths_cannot_be_forged(api): assert client.put(base + '/uploads/' + str(uuid4()), content=png(), headers={'X-Asset-Name': 'wrong.png', 'X-Asset-Kind': 'video'}).status_code == 400 +def test_copy_stages_another_projects_asset_on_the_server(api): + client, base, projects, source_id = api + _, _, source_asset = api_upload(client, base) + target_id = uuid4() + projects.execute(command(target_id, name='Target')) + session = client.headers['X-OpenDirector-Session'] + projects.open(target_id, session) + target = f'/api/creation/projects/{target_id}' + handle = str(uuid4()) + fingerprint = client.get(base + '/assets/' + source_asset).json()['fingerprint'] + source = dict(projectId=str(source_id), assetId=source_asset, fingerprint=fingerprint) + response = client.put(target + '/uploads/' + handle + '/copy', json=source) + assert response.status_code == 200, response.text + info = response.json() + assert info['status'] == 'ready' and info['name'] == 'frame.png' and info['kind'] == 'image' + # A retry of the same copy answers with the same staged upload. + assert client.put(target + '/uploads/' + handle + '/copy', json=source).json() == info + body = dict(type='asset.import', commandId=str(uuid4()), projectId=str(target_id), + payload=dict(uploadHandle=handle, name=info['name'], expectedKind=info['kind'])) + receipt = client.post(target + '/commands', json=body).json() + assert receipt['status'] == 'applied', receipt + copied = client.get(target + '/assets/' + receipt['changed'][0]['id']).json() + assert copied['projectId'] == str(target_id) and copied['provenance']['source'] == 'upload' + assert copied['fingerprint'] == fingerprint + assert client.get(target + '/assets/' + copied['id'] + '/content').content == png() + assert len(client.get(base + '/assets').json()) == 1 + + +def test_copy_requires_an_existing_source_and_an_open_target(api): + client, base, projects, source_id = api + _, _, source_asset = api_upload(client, base) + _, _, other = api_upload(client, base, data=png()[:-1] + b'\x00', name='other.png') + target_id = uuid4() + projects.execute(command(target_id, name='Target')) + target = f'/api/creation/projects/{target_id}/uploads/' + source = dict(projectId=str(source_id), assetId=source_asset) + # The target must be open in this session, as for a browser upload. + assert client.put(target + str(uuid4()) + '/copy', json=source).status_code == 409 + projects.open(target_id, client.headers['X-OpenDirector-Session']) + missing = dict(projectId=str(source_id), assetId=str(uuid4())) + assert client.put(target + str(uuid4()) + '/copy', json=missing).status_code == 404 + assert client.put(target + str(uuid4()) + '/copy', json={**source, 'path': '/etc/passwd'}).status_code == 422 + assert client.put(target + str(uuid4()) + '/copy', json={**source, 'fingerprint': 'sha256:' + '0' * 64}).status_code == 409 + # A handle used for one source cannot be reused for different content. + handle = str(uuid4()) + assert client.put(target + handle + '/copy', json=source).status_code == 200 + assert client.put(target + handle + '/copy', json=dict(projectId=str(source_id), assetId=other)).status_code == 409 + + def test_upload_response_loss_and_request_retry_keep_same_handle(api, monkeypatch): from opendirector.infrastructure import creation_uploads client, base, _, _ = api