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
7 changes: 6 additions & 1 deletion apps/api/src/opendirector_api/creation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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))
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/opendirector_api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/opendirector/application/creation_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions src/backend/creationClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down
40 changes: 25 additions & 15 deletions src/canvas/CanvasWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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} 个节点`);
};
Expand All @@ -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);
Expand Down Expand Up @@ -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),
};
},
);
Expand Down
65 changes: 54 additions & 11 deletions src/canvas/clipboardMedia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ const KINDS = new Set<CanvasResource["kind"]>([
]);

/** 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<string, Promise<Blob>>;
sources: Map<string, MediaSource>;
/** Registered copies per target project, so repeated pastes and retries reuse them. */
registered: Map<string, Promise<RegisteredMedia>>;
}
Expand All @@ -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<RegisteredMedia>;

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<string, unknown>,
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<string, unknown>, key: string, src: string) => void,
Expand All @@ -55,17 +76,34 @@ async function readBlob(src: string): Promise<Blob> {
export function captureClipboardMedia(
projectId: string,
fragment: CanvasFragment,
read: (src: string) => Promise<Blob> = readBlob,
{
read = readBlob,
resolve = () => undefined,
}: {
read?: (src: string) => Promise<Blob>;
/** The saved asset behind a field, from its recorded ID or its preview URL. */
resolve?: (
src: string,
assetId: string | undefined,
) => Omit<MediaSource, "projectId"> | undefined;
} = {},
): ClipboardMedia {
const blobs = new Map<string, Promise<Blob>>();
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<string, MediaSource>();
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() };
}

/**
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions tests/browser/cross_canvas_paste.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<id>")).sort(),
["PUT /uploads/<id>/copy", "PUT /uploads/<id>/copy"]);
const pasted = await until(async () => {
const snapshot = await target.state();
return snapshot.canvasItems.filter((item) => item.visible).length === 2 && snapshot;
Expand Down
60 changes: 57 additions & 3 deletions tests/clipboardMedia.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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-正脸");
});
Loading