Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8ce1d9031 | ||
|
|
edbefcc738 | ||
|
|
1713607572 | ||
|
|
e4aec01ea6 | ||
|
|
468bb5579d | ||
|
|
9d2aa879e9 | ||
|
|
d9e39702e0 | ||
|
|
7de633d4c9 | ||
|
|
b995662388 |
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"browsers": [
|
"browsers": [
|
||||||
{
|
{
|
||||||
"brand": "Google Chrome",
|
"brand": "Google Chrome",
|
||||||
"fullVersion": "150.0.7871.187"
|
"fullVersion": "151.0.0.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"brand": "Microsoft Edge",
|
"brand": "Microsoft Edge",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const forbiddenDiagnosticPatterns = [
|
|||||||
/https?:\/\//i,
|
/https?:\/\//i,
|
||||||
];
|
];
|
||||||
const safePauseReasons = new Set([
|
const safePauseReasons = new Set([
|
||||||
"asset_root_state_missing", "balance_insufficient", "configured_disabled", "contract_blocked",
|
"asset_manifest_invalid", "asset_root_missing", "asset_root_state_missing", "balance_insufficient", "configured_disabled", "contract_blocked",
|
||||||
"contract_unverified", "gateway_balance_insufficient", "gateway_paused", "health_check_failed",
|
"contract_unverified", "gateway_balance_insufficient", "gateway_paused", "health_check_failed",
|
||||||
"model_disabled", "provider_unavailable", "quota_exhausted", "service_state_missing", "unknown",
|
"model_disabled", "provider_unavailable", "quota_exhausted", "service_state_missing", "unknown",
|
||||||
"worker_degraded", "worker_state_missing", "worker_stopped",
|
"worker_degraded", "worker_state_missing", "worker_stopped",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:p
|
|||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const Database = require("better-sqlite3") as typeof import("better-sqlite3");
|
const Database = require("better-sqlite3") as typeof import("better-sqlite3");
|
||||||
|
|
||||||
const assetIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
|
||||||
const fixedDirectories = [
|
const fixedDirectories = [
|
||||||
"db",
|
"db",
|
||||||
"content/references",
|
"content/references",
|
||||||
@@ -82,13 +82,21 @@ export function readConfiguredLocalDataRoot(configFile = defaultInstanceConfigPa
|
|||||||
return resolve(candidate);
|
return resolve(candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readConfiguredAssetRoot(configFile = defaultInstanceConfigPath()) {
|
||||||
|
const configuration = JSON.parse(readFileSync(configFile, "utf8")) as Record<string, unknown>;
|
||||||
|
if (typeof configuration.asset_root !== "string" || !isAbsolute(configuration.asset_root)) {
|
||||||
|
throw new Error("asset_root_configuration_invalid");
|
||||||
|
}
|
||||||
|
return resolve(configuration.asset_root);
|
||||||
|
}
|
||||||
|
|
||||||
export interface ValidatedReadOnlyAssetRoot {
|
export interface ValidatedReadOnlyAssetRoot {
|
||||||
absolute_root: string;
|
absolute_root: string;
|
||||||
ok: true;
|
ok: true;
|
||||||
root_ref: string;
|
root_ref: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PublicAssetEntry {
|
export interface PublicAssetEntry {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
@@ -317,18 +325,19 @@ export function createPublicAssetResolver(input: {
|
|||||||
const roots = new Map(input.roots.map((root) => [root.root_ref, root.absolute_root]));
|
const roots = new Map(input.roots.map((root) => [root.root_ref, root.absolute_root]));
|
||||||
const entries = new Map<string, PublicAssetEntry>();
|
const entries = new Map<string, PublicAssetEntry>();
|
||||||
for (const entry of input.entries) {
|
for (const entry of input.entries) {
|
||||||
if (!assetIdPattern.test(entry.assetId) || entries.has(entry.assetId)) throw new Error("asset_id_invalid");
|
const key = `${entry.resourceVersion}\u0000${entry.assetId}`;
|
||||||
|
if (!assetIdPattern.test(entry.assetId) || entries.has(key)) throw new Error("asset_id_invalid");
|
||||||
if (!roots.has(entry.rootRef)) throw new Error("asset_root_unvalidated");
|
if (!roots.has(entry.rootRef)) throw new Error("asset_root_unvalidated");
|
||||||
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/i.test(entry.resourceVersion)) throw new Error("resource_version_invalid");
|
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/i.test(entry.resourceVersion)) throw new Error("resource_version_invalid");
|
||||||
if (!/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)) throw new Error("mime_type_invalid");
|
if (!/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)) throw new Error("mime_type_invalid");
|
||||||
entries.set(entry.assetId, { ...entry });
|
entries.set(key, { ...entry });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
read(resourceVersion, assetId) {
|
read(resourceVersion, assetId) {
|
||||||
if (!assetIdPattern.test(assetId)) return undefined;
|
if (!assetIdPattern.test(assetId)) return undefined;
|
||||||
const entry = entries.get(assetId);
|
const entry = entries.get(`${resourceVersion}\u0000${assetId}`);
|
||||||
if (!entry || entry.resourceVersion !== resourceVersion) return undefined;
|
if (!entry) return undefined;
|
||||||
const root = roots.get(entry.rootRef);
|
const root = roots.get(entry.rootRef);
|
||||||
if (!root) return undefined;
|
if (!root) return undefined;
|
||||||
let path: string;
|
let path: string;
|
||||||
|
|||||||
+29
-2
@@ -5,7 +5,7 @@ import { registrationNotice } from "@dada/shared-contracts";
|
|||||||
|
|
||||||
import { createApp } from "./app.js";
|
import { createApp } from "./app.js";
|
||||||
import { readBrowserSupportRelease } from "./browser-support.js";
|
import { readBrowserSupportRelease } from "./browser-support.js";
|
||||||
import { defaultInstanceConfigPath, ensureLocalDataRuntimeDirectories, readConfiguredLocalDataRoot } from "./local-data-root.js";
|
import { defaultInstanceConfigPath, ensureLocalDataRuntimeDirectories, readConfiguredLocalDataRoot, type PublicAssetResolver } from "./local-data-root.js";
|
||||||
import { ManagedStorage } from "./managed-storage.js";
|
import { ManagedStorage } from "./managed-storage.js";
|
||||||
import { LatestExportService } from "./latest-exports.js";
|
import { LatestExportService } from "./latest-exports.js";
|
||||||
import { CreditService } from "./credits.js";
|
import { CreditService } from "./credits.js";
|
||||||
@@ -16,10 +16,16 @@ import { MockResendAdapter } from "./resend-adapter.js";
|
|||||||
import { readSecureConfigCandidate } from "./secure-config.js";
|
import { readSecureConfigCandidate } from "./secure-config.js";
|
||||||
import { StructuredJsonlLogger } from "./structured-log.js";
|
import { StructuredJsonlLogger } from "./structured-log.js";
|
||||||
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
|
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
|
||||||
import { ModelConfigurationService, portableRuntimeModelCandidates } from "./model-configuration.js";
|
import { GenerationSubmissionService } from "./generation-submission.js";
|
||||||
|
import {
|
||||||
|
GenerationModelConfigurationCatalog,
|
||||||
|
ModelConfigurationService,
|
||||||
|
portableRuntimeModelCandidates,
|
||||||
|
} from "./model-configuration.js";
|
||||||
import { MockAmapAdapter, type AmapAdapter } from "./amap-adapter.js";
|
import { MockAmapAdapter, type AmapAdapter } from "./amap-adapter.js";
|
||||||
import { StickerReleaseService } from "./sticker-releases.js";
|
import { StickerReleaseService } from "./sticker-releases.js";
|
||||||
import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js";
|
import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js";
|
||||||
|
import { loadConfiguredRuntimeAssets, type RuntimeAssetState } from "./runtime-assets.js";
|
||||||
|
|
||||||
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
||||||
let registration: RegistrationService | undefined;
|
let registration: RegistrationService | undefined;
|
||||||
@@ -28,8 +34,11 @@ let credits: CreditService | undefined;
|
|||||||
let storage: ManagedStorage | undefined;
|
let storage: ManagedStorage | undefined;
|
||||||
let latestExports: LatestExportService | undefined;
|
let latestExports: LatestExportService | undefined;
|
||||||
let models: ModelConfigurationService | undefined;
|
let models: ModelConfigurationService | undefined;
|
||||||
|
let generations: GenerationSubmissionService | undefined;
|
||||||
let recentAssets: RecentAssetService | undefined;
|
let recentAssets: RecentAssetService | undefined;
|
||||||
let stickers: StickerReleaseService | undefined;
|
let stickers: StickerReleaseService | undefined;
|
||||||
|
let publicAssets: PublicAssetResolver | undefined;
|
||||||
|
let assetRootState: RuntimeAssetState | undefined;
|
||||||
let amap: AmapAdapter = new MockAmapAdapter();
|
let amap: AmapAdapter = new MockAmapAdapter();
|
||||||
let localTestAuth = false;
|
let localTestAuth = false;
|
||||||
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
||||||
@@ -43,6 +52,13 @@ if (credentialChannelEnabled) {
|
|||||||
.digest();
|
.digest();
|
||||||
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
|
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
|
||||||
ensureLocalDataRuntimeDirectories(dataRoot);
|
ensureLocalDataRuntimeDirectories(dataRoot);
|
||||||
|
const runtimeAssets = loadConfiguredRuntimeAssets({
|
||||||
|
configFile: instanceConfigPath,
|
||||||
|
dataRoot,
|
||||||
|
trustedManifestPath: resolve("asset-metadata", "manifest.json"),
|
||||||
|
});
|
||||||
|
publicAssets = runtimeAssets.publicAssets;
|
||||||
|
assetRootState = runtimeAssets.state;
|
||||||
const databasePath = join(dataRoot, "db", "dada.sqlite3");
|
const databasePath = join(dataRoot, "db", "dada.sqlite3");
|
||||||
registration = new RegistrationService({
|
registration = new RegistrationService({
|
||||||
adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper),
|
adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper),
|
||||||
@@ -59,6 +75,11 @@ if (credentialChannelEnabled) {
|
|||||||
stickers = new StickerReleaseService({ databasePath, storage });
|
stickers = new StickerReleaseService({ databasePath, storage });
|
||||||
latestExports = new LatestExportService({ databasePath, storage });
|
latestExports = new LatestExportService({ databasePath, storage });
|
||||||
models = new ModelConfigurationService({ database: registration.database, seedCandidates: portableRuntimeModelCandidates });
|
models = new ModelConfigurationService({ database: registration.database, seedCandidates: portableRuntimeModelCandidates });
|
||||||
|
generations = new GenerationSubmissionService({
|
||||||
|
credits,
|
||||||
|
models: new GenerationModelConfigurationCatalog(models),
|
||||||
|
storage,
|
||||||
|
});
|
||||||
recentAssets = new RecentAssetService({ database: registration.database });
|
recentAssets = new RecentAssetService({ database: registration.database });
|
||||||
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -68,6 +89,8 @@ if (credentialChannelEnabled) {
|
|||||||
stickers = undefined;
|
stickers = undefined;
|
||||||
latestExports?.close();
|
latestExports?.close();
|
||||||
latestExports = undefined;
|
latestExports = undefined;
|
||||||
|
generations?.close();
|
||||||
|
generations = undefined;
|
||||||
storage?.close();
|
storage?.close();
|
||||||
storage = undefined;
|
storage = undefined;
|
||||||
credits?.close();
|
credits?.close();
|
||||||
@@ -88,6 +111,7 @@ const adminServicesStorage = registration
|
|||||||
database: registration.database,
|
database: registration.database,
|
||||||
...(models ? { models } : {}),
|
...(models ? { models } : {}),
|
||||||
...(storage ? { storage } : {}),
|
...(storage ? { storage } : {}),
|
||||||
|
...(assetRootState ? { assetRoot: assetRootState } : {}),
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
const adminDiagnostics = adminServicesStorage
|
const adminDiagnostics = adminServicesStorage
|
||||||
@@ -102,10 +126,12 @@ const app = await createApp({
|
|||||||
amap,
|
amap,
|
||||||
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
||||||
...(credits ? { credits } : {}),
|
...(credits ? { credits } : {}),
|
||||||
|
...(generations ? { generations } : {}),
|
||||||
...(latestExports ? { latestExports } : {}),
|
...(latestExports ? { latestExports } : {}),
|
||||||
...(registration && localTestAuth ? { localTestAuth: true } : {}),
|
...(registration && localTestAuth ? { localTestAuth: true } : {}),
|
||||||
...(models ? { models } : {}),
|
...(models ? { models } : {}),
|
||||||
...(projects ? { projects } : {}),
|
...(projects ? { projects } : {}),
|
||||||
|
...(publicAssets ? { publicAssets } : {}),
|
||||||
...(registration ? { registration } : {}),
|
...(registration ? { registration } : {}),
|
||||||
...(recentAssets ? { recentAssets } : {}),
|
...(recentAssets ? { recentAssets } : {}),
|
||||||
...(stickers ? { stickers } : {}),
|
...(stickers ? { stickers } : {}),
|
||||||
@@ -125,6 +151,7 @@ if (controlPipeIndex >= 0) {
|
|||||||
await app.close();
|
await app.close();
|
||||||
amap.dispose?.();
|
amap.dispose?.();
|
||||||
latestExports?.close();
|
latestExports?.close();
|
||||||
|
generations?.close();
|
||||||
credits?.close();
|
credits?.close();
|
||||||
projects?.close();
|
projects?.close();
|
||||||
registration?.close();
|
registration?.close();
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { randomUUID, createHash } from "node:crypto";
|
|||||||
import type BetterSqlite3 from "better-sqlite3";
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
|
|
||||||
import { serializeAuditSummary, auditRetentionMilliseconds } from "./audit-policy.js";
|
import { serializeAuditSummary, auditRetentionMilliseconds } from "./audit-policy.js";
|
||||||
|
import type { GenerationModelCatalog, GenerationModelSnapshot } from "./generation-submission.js";
|
||||||
|
import { projectRatios } from "./projects.js";
|
||||||
|
|
||||||
export const modelIds = [
|
export const modelIds = [
|
||||||
"gemini-3.1-flash-image-preview",
|
"gemini-3.1-flash-image-preview",
|
||||||
@@ -59,6 +61,45 @@ export interface ModelConfigurationView {
|
|||||||
models: ModelConfigView[];
|
models: ModelConfigView[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReadableModelConfiguration = Pick<ModelConfigurationService, "read">;
|
||||||
|
|
||||||
|
function generationRuntimeReason(reason: ModelRuntimeReason): GenerationModelSnapshot["runtimeAvailability"]["reason"] {
|
||||||
|
if (reason === "gateway_balance_insufficient") return reason;
|
||||||
|
if (reason === "contract_unverified" || reason === "contract_blocked") return "gateway_contract_invalid";
|
||||||
|
if (reason === "available") return null;
|
||||||
|
return "model_disabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenerationModelConfigurationCatalog implements GenerationModelCatalog {
|
||||||
|
constructor(private readonly models: ReadableModelConfiguration) {}
|
||||||
|
|
||||||
|
readModel(modelId: string): GenerationModelSnapshot | undefined {
|
||||||
|
const configuration = this.models.read();
|
||||||
|
const model = configuration.models.find((entry) => entry.model_id === modelId);
|
||||||
|
if (!model) return undefined;
|
||||||
|
const supportedRatios = projectRatios.filter((ratio) => model.supported_ratios.includes(ratio));
|
||||||
|
return {
|
||||||
|
configSetVersion: configuration.config_set_version,
|
||||||
|
configVersion: model.config_version,
|
||||||
|
contractValidationStatus: model.contract_validation_status === "verified" ? "verified" : "unverified",
|
||||||
|
creditCost: model.credit_cost,
|
||||||
|
enabled: model.enabled,
|
||||||
|
modelId: model.model_id,
|
||||||
|
promptMaxLength: model.prompt_max_length,
|
||||||
|
referenceLimits: {
|
||||||
|
maxFileBytes: model.reference_limits.max_file_bytes,
|
||||||
|
maxFiles: model.reference_limits.max_files,
|
||||||
|
maxTotalBytes: model.reference_limits.max_total_bytes,
|
||||||
|
},
|
||||||
|
runtimeAvailability: {
|
||||||
|
availableForNewJobs: model.runtime_availability.available_for_new_jobs,
|
||||||
|
reason: generationRuntimeReason(model.runtime_availability.reason),
|
||||||
|
},
|
||||||
|
supportedRatios,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class ModelConfigurationError extends Error {
|
export class ModelConfigurationError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
readonly code:
|
readonly code:
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createPublicAssetResolver,
|
||||||
|
readConfiguredAssetRoot,
|
||||||
|
validateReadOnlyAssetRoot,
|
||||||
|
type PublicAssetEntry,
|
||||||
|
type PublicAssetResolver,
|
||||||
|
} from "./local-data-root.js";
|
||||||
|
|
||||||
|
const rootRef = "p0a_runtime_assets";
|
||||||
|
const schemaVersion = "DadaRuntimeAssets/v1";
|
||||||
|
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
|
||||||
|
const releasePattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;
|
||||||
|
const shaPattern = /^[a-f0-9]{64}$/i;
|
||||||
|
|
||||||
|
export interface RuntimeAssetState {
|
||||||
|
checked_at: string;
|
||||||
|
configured: boolean;
|
||||||
|
pause_reason: "asset_manifest_invalid" | "asset_root_missing" | "asset_root_state_missing" | null;
|
||||||
|
status: "active" | "unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadedRuntimeAssets {
|
||||||
|
publicAssets?: PublicAssetResolver;
|
||||||
|
state: RuntimeAssetState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRuntimeManifest(bytes: Buffer): PublicAssetEntry[] {
|
||||||
|
const value = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
|
||||||
|
if (value.schema_version !== schemaVersion || value.source !== "external_read_only" || value.root_ref !== rootRef) {
|
||||||
|
throw new Error("runtime_asset_manifest_invalid");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(value.entries) || value.entries.length === 0) throw new Error("runtime_asset_manifest_invalid");
|
||||||
|
return value.entries.map((candidate) => {
|
||||||
|
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) throw new Error("runtime_asset_manifest_invalid");
|
||||||
|
const entry = candidate as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof entry.assetId !== "string" || !assetIdPattern.test(entry.assetId)
|
||||||
|
|| typeof entry.mimeType !== "string" || !/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)
|
||||||
|
|| typeof entry.relativePath !== "string" || entry.relativePath.includes("\\") || entry.relativePath.split("/").includes("..")
|
||||||
|
|| typeof entry.resourceVersion !== "string" || !releasePattern.test(entry.resourceVersion)
|
||||||
|
|| entry.rootRef !== rootRef
|
||||||
|
|| typeof entry.sha256 !== "string" || !shaPattern.test(entry.sha256)
|
||||||
|
) throw new Error("runtime_asset_manifest_invalid");
|
||||||
|
return entry as unknown as PublicAssetEntry;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unavailable(
|
||||||
|
configured: boolean,
|
||||||
|
pauseReason: Exclude<RuntimeAssetState["pause_reason"], null>,
|
||||||
|
checkedAt: string,
|
||||||
|
): LoadedRuntimeAssets {
|
||||||
|
return { state: { checked_at: checkedAt, configured, pause_reason: pauseReason, status: "unavailable" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfiguredRuntimeAssets(input: {
|
||||||
|
configFile: string;
|
||||||
|
dataRoot: string;
|
||||||
|
trustedManifestPath: string;
|
||||||
|
clock?: () => number;
|
||||||
|
}): LoadedRuntimeAssets {
|
||||||
|
const checkedAt = new Date((input.clock ?? Date.now)()).toISOString();
|
||||||
|
let assetRoot: string;
|
||||||
|
try {
|
||||||
|
assetRoot = readConfiguredAssetRoot(input.configFile);
|
||||||
|
} catch {
|
||||||
|
return unavailable(false, "asset_root_state_missing", checkedAt);
|
||||||
|
}
|
||||||
|
if (!existsSync(input.trustedManifestPath)) return unavailable(true, "asset_manifest_invalid", checkedAt);
|
||||||
|
try {
|
||||||
|
const trustedBytes = readFileSync(input.trustedManifestPath);
|
||||||
|
const entries = parseRuntimeManifest(trustedBytes);
|
||||||
|
const validatedRoot = validateReadOnlyAssetRoot({
|
||||||
|
dataRoot: input.dataRoot,
|
||||||
|
expectedSha256: createHash("sha256").update(trustedBytes).digest("hex"),
|
||||||
|
manifestRelativePath: "manifest.json",
|
||||||
|
root: assetRoot,
|
||||||
|
rootRef,
|
||||||
|
});
|
||||||
|
if (!validatedRoot.ok) {
|
||||||
|
return unavailable(true, validatedRoot.reason === "asset_root_missing" ? "asset_root_missing" : "asset_manifest_invalid", checkedAt);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
publicAssets: createPublicAssetResolver({ entries, roots: [validatedRoot] }),
|
||||||
|
state: { checked_at: checkedAt, configured: true, pause_reason: null, status: "active" },
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return unavailable(true, "asset_manifest_invalid", checkedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
.editor-page-shell {
|
.editor-page-shell {
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 56px minmax(0, 1fr) 32px;
|
grid-template-rows: 56px minmax(0, 1fr) 32px;
|
||||||
background: #e8e8e5;
|
background: #e8e8e5;
|
||||||
color: #111111;
|
color: #111111;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-page-shell :focus-visible {
|
.editor-page-shell :focus-visible {
|
||||||
@@ -124,6 +127,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 280px minmax(0, 1fr) 320px;
|
grid-template-columns: 280px minmax(0, 1fr) 320px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-assets-panel,
|
.editor-assets-panel,
|
||||||
@@ -542,13 +546,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.editor-page-shell { grid-template-rows: auto minmax(0, 1fr) auto; }
|
.editor-page-shell { height: auto; min-height: 100dvh; grid-template-rows: auto minmax(0, 1fr) auto; overflow: visible; }
|
||||||
.editor-toolbar { display: flex; min-height: 56px; flex-wrap: wrap; gap: 8px; padding: 8px 10px; }
|
.editor-toolbar { display: flex; min-height: 56px; flex-wrap: wrap; gap: 8px; padding: 8px 10px; }
|
||||||
.editor-title { min-width: 0; flex: 1 1 calc(100% - 56px); }
|
.editor-title { min-width: 0; flex: 1 1 calc(100% - 56px); }
|
||||||
.editor-history-actions { order: 3; }
|
.editor-history-actions { order: 3; }
|
||||||
.editor-save-status { order: 4; flex: 1 1 128px; }
|
.editor-save-status { order: 4; flex: 1 1 128px; }
|
||||||
.editor-toolbar-controls > button { display: block; order: 5; }
|
.editor-toolbar-controls > button { display: block; order: 5; }
|
||||||
.editor-layout { grid-template-columns: 1fr; }
|
.editor-layout { grid-template-columns: 1fr; overflow: visible; }
|
||||||
.editor-assets-panel, .editor-inspector { border: 0; }
|
.editor-assets-panel, .editor-inspector { border: 0; }
|
||||||
.editor-assets-panel { order: 2; }
|
.editor-assets-panel { order: 2; }
|
||||||
.editor-inspector { order: 3; }
|
.editor-inspector { order: 3; }
|
||||||
|
|||||||
+128
-40
@@ -86,6 +86,14 @@ interface EditorExportResult {
|
|||||||
status: ExportFlowStatus;
|
status: ExportFlowStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withTextDraft(canvasState: CanvasState, textEdit: TextEditState | undefined) {
|
||||||
|
if (!textEdit) return canvasState;
|
||||||
|
return {
|
||||||
|
...canvasState,
|
||||||
|
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface EditorProject {
|
interface EditorProject {
|
||||||
canvas_state?: CanvasState;
|
canvas_state?: CanvasState;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -155,11 +163,26 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
|
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
|
||||||
const dragRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
|
const dragRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
|
||||||
const opacityGestureRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
|
const opacityGestureRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
|
||||||
|
const textHistoryRef = useRef<{ base: CanvasState; elementId: string; last: CanvasState } | undefined>(undefined);
|
||||||
const clipboardRef = useRef<CanvasElement[]>([]);
|
const clipboardRef = useRef<CanvasElement[]>([]);
|
||||||
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
|
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
|
||||||
const conflictExportGuardRef = useRef(new ConflictExportGuard());
|
const conflictExportGuardRef = useRef(new ConflictExportGuard());
|
||||||
const candidateMenuRef = useRef<HTMLDivElement | null>(null);
|
const candidateMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const candidateTriggerRef = useRef<HTMLButtonElement | null>(null);
|
const candidateTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const noticeTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
||||||
|
function showNotice(message: string) {
|
||||||
|
if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
|
||||||
|
setNotice(message);
|
||||||
|
noticeTimerRef.current = setTimeout(() => {
|
||||||
|
setNotice("");
|
||||||
|
noticeTimerRef.current = undefined;
|
||||||
|
}, 3_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -175,7 +198,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
setDraftAdjustments(initial.background.adjustments);
|
setDraftAdjustments(initial.background.adjustments);
|
||||||
historyRef.current = new CanvasEditHistory(initial);
|
historyRef.current = new CanvasEditHistory(initial);
|
||||||
elementControllerRef.current = new CanvasElementController(initial);
|
elementControllerRef.current = new CanvasElementController(initial);
|
||||||
}).catch(() => { if (active) setNotice("编辑器暂时无法读取项目"); });
|
}).catch(() => { if (active) showNotice("编辑器暂时无法读取项目"); });
|
||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
@@ -261,6 +284,10 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
});
|
});
|
||||||
}, [canvasState, selectedIds.join("|")]);
|
}, [canvasState, selectedIds.join("|")]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (textEdit) commitTextDraftAutomatically(textEdit);
|
||||||
|
}, [textEdit?.draft]);
|
||||||
|
|
||||||
async function ensureFont(fontId: string, url: string, retry = false) {
|
async function ensureFont(fontId: string, url: string, retry = false) {
|
||||||
const current = fontStatuses[fontId];
|
const current = fontStatuses[fontId];
|
||||||
if (current === "ready" || (current === "unavailable" && !retry)) return current;
|
if (current === "ready" || (current === "unavailable" && !retry)) return current;
|
||||||
@@ -277,24 +304,34 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
await Promise.all(available.map((template) => ensureFont(template.defaultFontId, template.fontUrl!, true)));
|
await Promise.all(available.map((template) => ensureFont(template.defaultFontId, template.fontUrl!, true)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitCanvas(next: CanvasState) {
|
function finalizeTextHistory() {
|
||||||
|
const pending = textHistoryRef.current;
|
||||||
|
if (!pending) return undefined;
|
||||||
|
textHistoryRef.current = undefined;
|
||||||
|
historyRef.current?.commit(pending.last);
|
||||||
|
return pending.last;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitCanvas(next: CanvasState, options: { preserveTextEdit?: boolean } = {}) {
|
||||||
if (!project || saveStatus === "conflicted") return;
|
if (!project || saveStatus === "conflicted") return;
|
||||||
historyRef.current?.commit(next);
|
const finalizedText = finalizeTextHistory();
|
||||||
|
if (!finalizedText || JSON.stringify(finalizedText) !== JSON.stringify(next)) historyRef.current?.commit(next);
|
||||||
elementControllerRef.current?.replaceState(next);
|
elementControllerRef.current?.replaceState(next);
|
||||||
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
|
||||||
setCanvasState(next);
|
setCanvasState(next);
|
||||||
setDraftAdjustments(next.background.adjustments);
|
setDraftAdjustments(next.background.adjustments);
|
||||||
queueRef.current?.commit({ canvas_state: next, name: project.name });
|
queueRef.current?.commit({ canvas_state: next, name: project.name });
|
||||||
setTextEdit(undefined);
|
if (!options.preserveTextEdit) setTextEdit(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPreview() {
|
function applyPreview() {
|
||||||
if (!canvasState) return;
|
if (!canvasState) return;
|
||||||
commitCanvas(updateBackgroundAdjustments(canvasState, draftAdjustments));
|
commitCanvas(updateBackgroundAdjustments(canvasState, draftAdjustments));
|
||||||
setNotice("底图调整已提交");
|
showNotice("底图调整已提交");
|
||||||
}
|
}
|
||||||
|
|
||||||
function undo() {
|
function undo() {
|
||||||
|
finalizeTextHistory();
|
||||||
const previous = historyRef.current?.undo();
|
const previous = historyRef.current?.undo();
|
||||||
if (previous) {
|
if (previous) {
|
||||||
elementControllerRef.current?.replaceState(previous);
|
elementControllerRef.current?.replaceState(previous);
|
||||||
@@ -307,6 +344,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function redo() {
|
function redo() {
|
||||||
|
finalizeTextHistory();
|
||||||
const next = historyRef.current?.redo();
|
const next = historyRef.current?.redo();
|
||||||
if (next) {
|
if (next) {
|
||||||
elementControllerRef.current?.replaceState(next);
|
elementControllerRef.current?.replaceState(next);
|
||||||
@@ -334,9 +372,9 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
const palette = await paletteForAsset(pendingBackground);
|
const palette = await paletteForAsset(pendingBackground);
|
||||||
commitCanvas(switchBackground(canvasState, pendingBackground, palette));
|
commitCanvas(switchBackground(canvasState, pendingBackground, palette));
|
||||||
setPendingBackground(undefined);
|
setPendingBackground(undefined);
|
||||||
setNotice("已更换底图,覆盖元素保留,底图处理已重置");
|
showNotice("已更换底图,覆盖元素保留,底图处理已重置");
|
||||||
} catch {
|
} catch {
|
||||||
setNotice("新底图无法读取,未更换底图或刷新色卡");
|
showNotice("新底图无法读取,未更换底图或刷新色卡");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +391,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
commitCanvas(next);
|
commitCanvas(next);
|
||||||
setGuides([]);
|
setGuides([]);
|
||||||
setCandidateMenu(undefined);
|
setCandidateMenu(undefined);
|
||||||
setNotice(message);
|
showNotice(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addSticker(sticker: StaticStickerCatalogItem) {
|
function addSticker(sticker: StaticStickerCatalogItem) {
|
||||||
@@ -369,8 +407,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}));
|
}));
|
||||||
commitElementOperation(controller, "贴纸已加入画布");
|
commitElementOperation(controller, "贴纸已加入画布");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
else setNotice("贴纸未能加入画布");
|
else showNotice("贴纸未能加入画布");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,8 +421,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
controller.add(createColorCardElement(definition, palette, newElementIdentity(), canvasState.elements.length));
|
controller.add(createColorCardElement(definition, palette, newElementIdentity(), canvasState.elements.length));
|
||||||
commitElementOperation(controller, "色卡已按原始底图加入画布");
|
commitElementOperation(controller, "色卡已按原始底图加入画布");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
else setNotice("无法从原始底图稳定提取五色,色卡未加入画布");
|
else showNotice("无法从原始底图稳定提取五色,色卡未加入画布");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +432,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
if (templateId === "DYN012") {
|
if (templateId === "DYN012") {
|
||||||
const font = fontOption("FONT081");
|
const font = fontOption("FONT081");
|
||||||
if (!font || await ensureFont(font.fontId, font.url) !== "ready") {
|
if (!font || await ensureFont(font.fontId, font.url) !== "ready") {
|
||||||
setNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
|
showNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -407,8 +445,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
commitElementOperation(controller, "动态值已确认并加入画布");
|
commitElementOperation(controller, "动态值已确认并加入画布");
|
||||||
setLocationDialog(undefined);
|
setLocationDialog(undefined);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
else setNotice("动态贴纸未能加入画布");
|
else showNotice("动态贴纸未能加入画布");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,7 +494,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
controller.replaceElement(overrideDynamicStickerValue(element, value));
|
controller.replaceElement(overrideDynamicStickerValue(element, value));
|
||||||
commitElementOperation(controller, "动态贴纸显示文字已更新");
|
commitElementOperation(controller, "动态贴纸显示文字已更新");
|
||||||
} catch {
|
} catch {
|
||||||
setNotice("动态贴纸显示文字不能为空");
|
showNotice("动态贴纸显示文字不能为空");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,7 +518,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
if (!template.fontUrl || !canvasState) return;
|
if (!template.fontUrl || !canvasState) return;
|
||||||
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
||||||
if (status !== "ready") {
|
if (status !== "ready") {
|
||||||
setNotice("素材暂不可用,未使用系统字体替代。");
|
showNotice("素材暂不可用,未使用系统字体替代。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const controller = controllerForCurrent();
|
const controller = controllerForCurrent();
|
||||||
@@ -490,8 +528,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
commitElementOperation(controller, "文字模板已加入画布");
|
commitElementOperation(controller, "文字模板已加入画布");
|
||||||
void recordRecentTextTemplate(template.templateId, template.resourceVersion);
|
void recordRecentTextTemplate(template.templateId, template.resourceVersion);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
else setNotice("文字模板未能加入画布");
|
else showNotice("文字模板未能加入画布");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,18 +541,49 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
action(edit);
|
action(edit);
|
||||||
return { ...current, draft: edit.value };
|
return { ...current, draft: edit.value };
|
||||||
} catch {
|
} catch {
|
||||||
setNotice("文字参数不在允许范围内");
|
showNotice("文字参数不在允许范围内");
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function commitTextDraftAutomatically(editState: TextEditState) {
|
||||||
|
if (!canvasState || !project || saveStatus === "conflicted") return;
|
||||||
|
const index = canvasState.elements.findIndex((element) => element.element_id === editState.elementId);
|
||||||
|
if (index < 0 || JSON.stringify(canvasState.elements[index]) === JSON.stringify(editState.draft)) return;
|
||||||
|
try {
|
||||||
|
const complete = new TextEditSession(editState.draft, P0A_TEXT_TEMPLATES).complete();
|
||||||
|
const next = structuredClone(canvasState);
|
||||||
|
next.elements[index] = complete;
|
||||||
|
const history = textHistoryRef.current;
|
||||||
|
if (!history || history.elementId !== editState.elementId) {
|
||||||
|
if (history) finalizeTextHistory();
|
||||||
|
textHistoryRef.current = { base: canvasState, elementId: editState.elementId, last: next };
|
||||||
|
} else {
|
||||||
|
history.last = next;
|
||||||
|
}
|
||||||
|
elementControllerRef.current?.replaceState(next);
|
||||||
|
setCanvasState(next);
|
||||||
|
queueRef.current?.commit({ canvas_state: next, name: project.name });
|
||||||
|
if (complete.template_or_asset_id !== editState.originalTemplateId) {
|
||||||
|
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
|
||||||
|
}
|
||||||
|
setTextEdit((current) => current?.elementId === editState.elementId ? {
|
||||||
|
...current,
|
||||||
|
draft: complete,
|
||||||
|
originalTemplateId: complete.template_or_asset_id,
|
||||||
|
} : current);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error && error.message === "text_content_required")) showNotice("文字编辑未能自动保存");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function changeTextTemplate(templateId: string) {
|
async function changeTextTemplate(templateId: string) {
|
||||||
const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId);
|
const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId);
|
||||||
if (!template?.fontUrl) return;
|
if (!template?.fontUrl) return;
|
||||||
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
const status = await ensureFont(template.defaultFontId, template.fontUrl);
|
||||||
if (status !== "ready") {
|
if (status !== "ready") {
|
||||||
setNotice("素材暂不可用,未使用系统字体替代。");
|
showNotice("素材暂不可用,未使用系统字体替代。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateTextDraft((edit) => edit.switchTemplate(templateId));
|
updateTextDraft((edit) => edit.switchTemplate(templateId));
|
||||||
@@ -527,7 +596,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
const option = fontOption(fontId);
|
const option = fontOption(fontId);
|
||||||
if (!option || await ensureFont(option.fontId, option.url) !== "ready") {
|
if (!option || await ensureFont(option.fontId, option.url) !== "ready") {
|
||||||
setNotice("字体素材暂不可用,未使用系统字体替代。");
|
showNotice("字体素材暂不可用,未使用系统字体替代。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateTextDraft((edit) => edit.setStyle({ fontOverride: fontId }));
|
updateTextDraft((edit) => edit.setStyle({ fontOverride: fontId }));
|
||||||
@@ -535,6 +604,11 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
function completeTextEdit() {
|
function completeTextEdit() {
|
||||||
if (!textEdit) return;
|
if (!textEdit) return;
|
||||||
|
if (!pendingTextDraft()) {
|
||||||
|
finalizeTextHistory();
|
||||||
|
showNotice("文字修改已进入自动保存");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const edit = new TextEditSession(textEdit.draft, P0A_TEXT_TEMPLATES);
|
const edit = new TextEditSession(textEdit.draft, P0A_TEXT_TEMPLATES);
|
||||||
const complete = edit.complete();
|
const complete = edit.complete();
|
||||||
@@ -546,17 +620,25 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
|
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "text_content_required") setNotice("请输入文字内容或删除该元素。");
|
if (error instanceof Error && error.message === "text_content_required") showNotice("请输入文字内容或删除该元素。");
|
||||||
else setNotice("文字编辑未能完成");
|
else showNotice("文字编辑未能完成");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelTextEdit() {
|
function cancelTextEdit() {
|
||||||
|
const pendingHistory = textHistoryRef.current;
|
||||||
|
if (pendingHistory && project) {
|
||||||
|
textHistoryRef.current = undefined;
|
||||||
|
elementControllerRef.current?.replaceState(pendingHistory.base);
|
||||||
|
setCanvasState(pendingHistory.base);
|
||||||
|
queueRef.current?.commit({ canvas_state: pendingHistory.base, name: project.name });
|
||||||
|
}
|
||||||
if (canvasState && textEdit) {
|
if (canvasState && textEdit) {
|
||||||
const current = canvasState.elements.find((element) => element.element_id === textEdit.elementId);
|
const source = pendingHistory?.base ?? canvasState;
|
||||||
|
const current = source.elements.find((element) => element.element_id === textEdit.elementId);
|
||||||
if (current) setTextEdit({ draft: structuredClone(current), elementId: current.element_id, originalTemplateId: current.template_or_asset_id });
|
if (current) setTextEdit({ draft: structuredClone(current), elementId: current.element_id, originalTemplateId: current.template_or_asset_id });
|
||||||
}
|
}
|
||||||
setNotice("已取消未提交的文字修改");
|
showNotice("已取消未提交的文字修改");
|
||||||
}
|
}
|
||||||
|
|
||||||
function pendingTextDraft() {
|
function pendingTextDraft() {
|
||||||
@@ -624,7 +706,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ran = await conflictExportGuardRef.current.run(() => executeExport(options));
|
const ran = await conflictExportGuardRef.current.run(() => executeExport(options));
|
||||||
if (!ran) setNotice("版本冲突时仅允许导出本页版本一次");
|
if (!ran) showNotice("版本冲突时仅允许导出本页版本一次");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function retryExportDownload() {
|
async function retryExportDownload() {
|
||||||
@@ -639,8 +721,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
action(controller);
|
action(controller);
|
||||||
commitElementOperation(controller, message);
|
commitElementOperation(controller, message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
else setNotice("对象操作未完成");
|
else showNotice("对象操作未完成");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,7 +750,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
opacityGestureRef.current = undefined;
|
opacityGestureRef.current = undefined;
|
||||||
if (gesture.last === gesture.base) return;
|
if (gesture.last === gesture.base) return;
|
||||||
commitCanvas(gesture.last);
|
commitCanvas(gesture.last);
|
||||||
setNotice("贴纸透明度已提交");
|
showNotice("贴纸透明度已提交");
|
||||||
}
|
}
|
||||||
|
|
||||||
function duplicateSelection() {
|
function duplicateSelection() {
|
||||||
@@ -688,7 +770,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
const controller = controllerForCurrent();
|
const controller = controllerForCurrent();
|
||||||
if (!controller || selectedIds.length === 0) return;
|
if (!controller || selectedIds.length === 0) return;
|
||||||
clipboardRef.current = controller.copySelected();
|
clipboardRef.current = controller.copySelected();
|
||||||
setNotice("已复制到画布剪贴板");
|
showNotice("已复制到画布剪贴板");
|
||||||
}
|
}
|
||||||
|
|
||||||
function pasteSelection() {
|
function pasteSelection() {
|
||||||
@@ -698,18 +780,20 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
controller.pasteElements(clipboardRef.current, () => newElementIdentity());
|
controller.pasteElements(clipboardRef.current, () => newElementIdentity());
|
||||||
commitElementOperation(controller, "已粘贴画布对象");
|
commitElementOperation(controller, "已粘贴画布对象");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAt(point: CanvasPoint, append: boolean) {
|
function selectAt(point: CanvasPoint, append: boolean) {
|
||||||
|
finalizeTextHistory();
|
||||||
const controller = controllerForCurrent();
|
const controller = controllerForCurrent();
|
||||||
if (!controller || !canvasState) return false;
|
if (!controller || !canvasState) return false;
|
||||||
const candidates = controller.candidatesAt(point);
|
const candidates = controller.candidatesAt(point);
|
||||||
const selection = controller.selectAt(point, { append: append || multiMode });
|
const selection = controller.selectAt(point, { append: append || multiMode });
|
||||||
setSelectedIds(selection);
|
setSelectedIds(selection);
|
||||||
setCandidateMenu(undefined);
|
setCandidateMenu(undefined);
|
||||||
dragRef.current = { base: canvasState, last: canvasState, selectedIds: selection };
|
const dragBase = withTextDraft(canvasState, textEdit);
|
||||||
|
dragRef.current = { base: dragBase, last: dragBase, selectedIds: selection };
|
||||||
return candidates.length > 0;
|
return candidates.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,19 +805,25 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
const preview = previewController.moveSelected(delta);
|
const preview = previewController.moveSelected(delta);
|
||||||
drag.last = preview.state;
|
drag.last = preview.state;
|
||||||
setCanvasState(preview.state);
|
setCanvasState(preview.state);
|
||||||
|
setTextEdit((current) => {
|
||||||
|
if (!current || !drag.selectedIds.includes(current.elementId)) return current;
|
||||||
|
const movedDraft = preview.state.elements.find((element) => element.element_id === current.elementId);
|
||||||
|
return movedDraft ? { ...current, draft: movedDraft } : current;
|
||||||
|
});
|
||||||
setGuides(preview.guides);
|
setGuides(preview.guides);
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitMove() {
|
function commitMove() {
|
||||||
const drag = dragRef.current;
|
const drag = dragRef.current;
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
commitCanvas(drag.last);
|
commitCanvas(drag.last, { preserveTextEdit: true });
|
||||||
setNotice("对象位置已提交");
|
showNotice("对象位置已提交");
|
||||||
setGuides([]);
|
setGuides([]);
|
||||||
dragRef.current = undefined;
|
dragRef.current = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function marqueeSelect(rectangle: CanvasRect, append: boolean) {
|
function marqueeSelect(rectangle: CanvasRect, append: boolean) {
|
||||||
|
finalizeTextHistory();
|
||||||
const controller = controllerForCurrent();
|
const controller = controllerForCurrent();
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
setSelectedIds(controller.marqueeSelect(rectangle, append || multiMode));
|
setSelectedIds(controller.marqueeSelect(rectangle, append || multiMode));
|
||||||
@@ -782,6 +872,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
|
finalizeTextHistory();
|
||||||
elementControllerRef.current?.clearSelection();
|
elementControllerRef.current?.clearSelection();
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
setCandidateMenu(undefined);
|
setCandidateMenu(undefined);
|
||||||
@@ -789,10 +880,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite">正在加载编辑器</main>;
|
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite">正在加载编辑器</main>;
|
||||||
const renderedCanvasState = textEdit ? {
|
const renderedCanvasState = withTextDraft(canvasState, textEdit);
|
||||||
...canvasState,
|
|
||||||
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
|
|
||||||
} : canvasState;
|
|
||||||
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
|
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
|
||||||
const canEdit = saveStatus !== "conflicted";
|
const canEdit = saveStatus !== "conflicted";
|
||||||
const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id));
|
const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id));
|
||||||
@@ -869,7 +957,6 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
maxWidth: `min(720px, calc(${(canvasState.pixel_width / canvasState.pixel_height * 100).toFixed(4)}vh - ${(canvasState.pixel_width / canvasState.pixel_height * 168).toFixed(4)}px))`,
|
maxWidth: `min(720px, calc(${(canvasState.pixel_width / canvasState.pixel_height * 100).toFixed(4)}vh - ${(canvasState.pixel_width / canvasState.pixel_height * 168).toFixed(4)}px))`,
|
||||||
}}>
|
}}>
|
||||||
<EditorStage
|
<EditorStage
|
||||||
assetId={canvasState.background.asset_id}
|
|
||||||
canvasState={renderedCanvasState}
|
canvasState={renderedCanvasState}
|
||||||
fontStatuses={fontStatuses}
|
fontStatuses={fontStatuses}
|
||||||
guides={guides}
|
guides={guides}
|
||||||
@@ -877,6 +964,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
|||||||
onClearSelection={clearSelection}
|
onClearSelection={clearSelection}
|
||||||
onCopy={copySelection}
|
onCopy={copySelection}
|
||||||
onDelete={deleteSelection}
|
onDelete={deleteSelection}
|
||||||
|
onDragStart={() => setCandidateMenu(undefined)}
|
||||||
onMarquee={marqueeSelect}
|
onMarquee={marqueeSelect}
|
||||||
onMoveCommit={commitMove}
|
onMoveCommit={commitMove}
|
||||||
onMovePreview={previewMove}
|
onMovePreview={previewMove}
|
||||||
|
|||||||
@@ -12,14 +12,18 @@ import { drawColorCard } from "./palette-provider.js";
|
|||||||
|
|
||||||
interface Gesture {
|
interface Gesture {
|
||||||
append: boolean;
|
append: boolean;
|
||||||
|
bounds: DOMRect;
|
||||||
hit: boolean;
|
hit: boolean;
|
||||||
longPressOpened: boolean;
|
longPressOpened: boolean;
|
||||||
|
moved: boolean;
|
||||||
pointerId: number;
|
pointerId: number;
|
||||||
start: CanvasPoint;
|
start: CanvasPoint;
|
||||||
|
startClient: CanvasPoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD_PX = 4;
|
||||||
|
|
||||||
interface EditorStageProps {
|
interface EditorStageProps {
|
||||||
assetId: string | null;
|
|
||||||
canvasState: CanvasState;
|
canvasState: CanvasState;
|
||||||
guides: readonly string[];
|
guides: readonly string[];
|
||||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
|
||||||
@@ -27,6 +31,7 @@ interface EditorStageProps {
|
|||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onDragStart: () => void;
|
||||||
onMarquee: (rectangle: CanvasRect, append: boolean) => void;
|
onMarquee: (rectangle: CanvasRect, append: boolean) => void;
|
||||||
onMoveCommit: () => void;
|
onMoveCommit: () => void;
|
||||||
onMovePreview: (delta: CanvasPoint) => void;
|
onMovePreview: (delta: CanvasPoint) => void;
|
||||||
@@ -38,11 +43,10 @@ interface EditorStageProps {
|
|||||||
selectedIds: readonly string[];
|
selectedIds: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function pointFromEvent(event: PointerEvent<HTMLCanvasElement>): CanvasPoint {
|
function pointFromClient(clientX: number, clientY: number, bounds: DOMRect): CanvasPoint {
|
||||||
const bounds = event.currentTarget.getBoundingClientRect();
|
|
||||||
return {
|
return {
|
||||||
x: Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)),
|
x: Math.max(0, Math.min(1, (clientX - bounds.left) / bounds.width)),
|
||||||
y: Math.max(0, Math.min(1, (event.clientY - bounds.top) / bounds.height)),
|
y: Math.max(0, Math.min(1, (clientY - bounds.top) / bounds.height)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +244,27 @@ function loadCanvasImage(url: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CanvasImageLoader = (url: string) => Promise<HTMLImageElement | undefined>;
|
||||||
|
|
||||||
|
interface SceneResources {
|
||||||
|
background: HTMLImageElement | undefined;
|
||||||
|
resourceImages: Readonly<Record<string, HTMLImageElement>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCachedCanvasImageLoader(): CanvasImageLoader {
|
||||||
|
const cache = new Map<string, Promise<HTMLImageElement | undefined>>();
|
||||||
|
return (url) => {
|
||||||
|
const cached = cache.get(url);
|
||||||
|
if (cached) return cached;
|
||||||
|
const pending = loadCanvasImage(url).then((image) => {
|
||||||
|
if (!image) cache.delete(url);
|
||||||
|
return image;
|
||||||
|
});
|
||||||
|
cache.set(url, pending);
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resourceUrlsForCanvas(canvasState: CanvasState) {
|
function resourceUrlsForCanvas(canvasState: CanvasState) {
|
||||||
const imageReferences = new Map<string, string>();
|
const imageReferences = new Map<string, string>();
|
||||||
for (const element of canvasState.elements) {
|
for (const element of canvasState.elements) {
|
||||||
@@ -252,11 +277,19 @@ function resourceUrlsForCanvas(canvasState: CanvasState) {
|
|||||||
return imageReferences;
|
return imageReferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSceneResources(canvasState: CanvasState, projectId: string) {
|
function sceneResourceKey(canvasState: CanvasState, projectId: string) {
|
||||||
const background = canvasState.background.asset_id
|
const background = canvasState.background.asset_id
|
||||||
? loadCanvasImage(`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`)
|
? `/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`
|
||||||
|
: null;
|
||||||
|
const resources = [...resourceUrlsForCanvas(canvasState)].toSorted(([left], [right]) => left.localeCompare(right));
|
||||||
|
return JSON.stringify({ background, projectId, resources });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSceneResources(canvasState: CanvasState, projectId: string, loadImage: CanvasImageLoader = loadCanvasImage): Promise<SceneResources> {
|
||||||
|
const background = canvasState.background.asset_id
|
||||||
|
? loadImage(`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`)
|
||||||
: Promise.resolve(undefined);
|
: Promise.resolve(undefined);
|
||||||
const resources = Promise.all([...resourceUrlsForCanvas(canvasState)].map(async ([assetId, url]) => [assetId, await loadCanvasImage(url)] as const));
|
const resources = Promise.all([...resourceUrlsForCanvas(canvasState)].map(async ([assetId, url]) => [assetId, await loadImage(url)] as const));
|
||||||
const [image, loaded] = await Promise.all([background, resources]);
|
const [image, loaded] = await Promise.all([background, resources]);
|
||||||
return {
|
return {
|
||||||
background: image,
|
background: image,
|
||||||
@@ -314,16 +347,29 @@ export function EditorStage(props: EditorStageProps) {
|
|||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const gestureRef = useRef<Gesture | undefined>(undefined);
|
const gestureRef = useRef<Gesture | undefined>(undefined);
|
||||||
const longPressRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
const longPressRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
const imageLoaderRef = useRef<CanvasImageLoader | undefined>(undefined);
|
||||||
|
const [sceneResources, setSceneResources] = useState<{ key: string; resources: SceneResources }>();
|
||||||
const [marquee, setMarquee] = useState<CanvasRect>();
|
const [marquee, setMarquee] = useState<CanvasRect>();
|
||||||
|
const resourceKey = sceneResourceKey(props.canvasState, props.projectId);
|
||||||
|
|
||||||
|
if (!imageLoaderRef.current) imageLoaderRef.current = createCachedCanvasImageLoader();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
|
void loadSceneResources(props.canvasState, props.projectId, imageLoaderRef.current).then((resources) => {
|
||||||
|
if (active) setSceneResources({ key: resourceKey, resources });
|
||||||
|
});
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [props.projectId, resourceKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return undefined;
|
if (!canvas) return undefined;
|
||||||
canvas.width = props.canvasState.pixel_width;
|
if (canvas.width !== props.canvasState.pixel_width) canvas.width = props.canvasState.pixel_width;
|
||||||
canvas.height = props.canvasState.pixel_height;
|
if (canvas.height !== props.canvasState.pixel_height) canvas.height = props.canvasState.pixel_height;
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
if (!context) return undefined;
|
if (!context) return undefined;
|
||||||
|
if (!sceneResources || sceneResources.key !== resourceKey) return undefined;
|
||||||
const render = (image: HTMLImageElement | undefined, resourceImages: Readonly<Record<string, HTMLImageElement>>) => {
|
const render = (image: HTMLImageElement | undefined, resourceImages: Readonly<Record<string, HTMLImageElement>>) => {
|
||||||
renderEditorScene(context, props.canvasState, props.fontStatuses, image, resourceImages);
|
renderEditorScene(context, props.canvasState, props.fontStatuses, image, resourceImages);
|
||||||
context.lineWidth = 4;
|
context.lineWidth = 4;
|
||||||
@@ -345,21 +391,22 @@ export function EditorStage(props: EditorStageProps) {
|
|||||||
if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height);
|
if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height);
|
||||||
context.restore();
|
context.restore();
|
||||||
};
|
};
|
||||||
void loadSceneResources(props.canvasState, props.projectId).then(({ background, resourceImages }) => {
|
render(sceneResources.resources.background, sceneResources.resources.resourceImages);
|
||||||
if (!active) return;
|
return undefined;
|
||||||
render(background, resourceImages);
|
}, [marquee, props.canvasState, props.fontStatuses, props.guides, props.selectedIds, resourceKey, sceneResources]);
|
||||||
});
|
|
||||||
return () => { active = false; };
|
|
||||||
}, [marquee, props.assetId, props.canvasState, props.fontStatuses, props.guides, props.projectId, props.selectedIds]);
|
|
||||||
|
|
||||||
useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []);
|
useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []);
|
||||||
|
|
||||||
function handlePointerDown(event: PointerEvent<HTMLCanvasElement>) {
|
function handlePointerDown(event: PointerEvent<HTMLCanvasElement>) {
|
||||||
if (event.button !== 0) return;
|
if (event.button !== 0) return;
|
||||||
const start = pointFromEvent(event);
|
const bounds = event.currentTarget.getBoundingClientRect();
|
||||||
|
const start = pointFromClient(event.clientX, event.clientY, bounds);
|
||||||
const append = event.shiftKey;
|
const append = event.shiftKey;
|
||||||
const hit = props.onSelect(start, append);
|
const hit = props.onSelect(start, append);
|
||||||
gestureRef.current = { append, hit, longPressOpened: false, pointerId: event.pointerId, start };
|
gestureRef.current = {
|
||||||
|
append, bounds, hit, longPressOpened: false, moved: false, pointerId: event.pointerId, start,
|
||||||
|
startClient: { x: event.clientX, y: event.clientY },
|
||||||
|
};
|
||||||
event.currentTarget.setPointerCapture(event.pointerId);
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
longPressRef.current = setTimeout(() => {
|
longPressRef.current = setTimeout(() => {
|
||||||
const gesture = gestureRef.current;
|
const gesture = gestureRef.current;
|
||||||
@@ -375,9 +422,15 @@ export function EditorStage(props: EditorStageProps) {
|
|||||||
props.onPointerMoved();
|
props.onPointerMoved();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const point = pointFromEvent(event);
|
const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y);
|
||||||
|
if (!gesture.moved) {
|
||||||
|
if (clientDistance < DRAG_THRESHOLD_PX) return;
|
||||||
|
gesture.moved = true;
|
||||||
|
gesture.longPressOpened = false;
|
||||||
|
props.onDragStart();
|
||||||
|
}
|
||||||
|
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
|
||||||
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
||||||
if (Math.abs(delta.x) + Math.abs(delta.y) < 0.003) return;
|
|
||||||
if (longPressRef.current) clearTimeout(longPressRef.current);
|
if (longPressRef.current) clearTimeout(longPressRef.current);
|
||||||
props.onPointerMoved();
|
props.onPointerMoved();
|
||||||
if (gesture.hit && !gesture.longPressOpened) props.onMovePreview(delta);
|
if (gesture.hit && !gesture.longPressOpened) props.onMovePreview(delta);
|
||||||
@@ -388,11 +441,18 @@ export function EditorStage(props: EditorStageProps) {
|
|||||||
const gesture = gestureRef.current;
|
const gesture = gestureRef.current;
|
||||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||||
if (longPressRef.current) clearTimeout(longPressRef.current);
|
if (longPressRef.current) clearTimeout(longPressRef.current);
|
||||||
const point = pointFromEvent(event);
|
const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y);
|
||||||
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
const moved = gesture.moved || clientDistance >= DRAG_THRESHOLD_PX;
|
||||||
const moved = Math.abs(delta.x) + Math.abs(delta.y) >= 0.003;
|
if (moved && !gesture.moved && !gesture.longPressOpened) {
|
||||||
|
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
|
||||||
|
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
||||||
|
if (gesture.hit) props.onMovePreview(delta);
|
||||||
|
}
|
||||||
if (gesture.hit && moved && !gesture.longPressOpened) props.onMoveCommit();
|
if (gesture.hit && moved && !gesture.longPressOpened) props.onMoveCommit();
|
||||||
else if (!gesture.hit && moved) props.onMarquee({ height: delta.y, width: delta.x, x: gesture.start.x, y: gesture.start.y }, gesture.append);
|
else if (!gesture.hit && moved) {
|
||||||
|
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
|
||||||
|
props.onMarquee({ height: point.y - gesture.start.y, width: point.x - gesture.start.x, x: gesture.start.x, y: gesture.start.y }, gesture.append);
|
||||||
|
}
|
||||||
setMarquee(undefined);
|
setMarquee(undefined);
|
||||||
gestureRef.current = undefined;
|
gestureRef.current = undefined;
|
||||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
:root {
|
||||||
|
--dada-interaction-duration: 120ms;
|
||||||
|
--dada-interaction-easing: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(button:not(:disabled), a[href], label:has(input:not(:disabled))) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(button:not(:disabled), a[href]) {
|
||||||
|
transition:
|
||||||
|
transform var(--dada-interaction-duration) var(--dada-interaction-easing),
|
||||||
|
box-shadow var(--dada-interaction-duration) var(--dada-interaction-easing),
|
||||||
|
border-color var(--dada-interaction-duration) ease,
|
||||||
|
background-color var(--dada-interaction-duration) ease,
|
||||||
|
color var(--dada-interaction-duration) ease,
|
||||||
|
opacity var(--dada-interaction-duration) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(input:not(:disabled), select:not(:disabled), textarea:not(:disabled)) {
|
||||||
|
transition:
|
||||||
|
border-color var(--dada-interaction-duration) ease,
|
||||||
|
box-shadow var(--dada-interaction-duration) ease,
|
||||||
|
background-color var(--dada-interaction-duration) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.product-page, .product-loading) :focus-visible {
|
||||||
|
outline: 2px solid #005fcc;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(input:not(:disabled), select:not(:disabled), textarea:not(:disabled)):focus-visible {
|
||||||
|
border-color: #005fcc;
|
||||||
|
box-shadow: 0 0 0 3px rgb(0 95 204 / 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card,
|
||||||
|
.project-preview img,
|
||||||
|
.ratio-control span,
|
||||||
|
.reference-input,
|
||||||
|
.editor-sticker-preview,
|
||||||
|
.editor-template-mark,
|
||||||
|
.editor-color-card-preview,
|
||||||
|
.editor-dynamic-preview,
|
||||||
|
.editor-source-preview-canvas,
|
||||||
|
.editor-thumb {
|
||||||
|
transition:
|
||||||
|
transform var(--dada-interaction-duration) var(--dada-interaction-easing),
|
||||||
|
box-shadow var(--dada-interaction-duration) var(--dada-interaction-easing),
|
||||||
|
border-color var(--dada-interaction-duration) ease,
|
||||||
|
background-color var(--dada-interaction-duration) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
:where(button:not(:disabled)):hover {
|
||||||
|
border-color: #111111;
|
||||||
|
box-shadow: 0 2px 0 rgb(17 17 17 / 35%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(a[href]):hover {
|
||||||
|
color: #005fcc;
|
||||||
|
opacity: 0.78;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-header nav a:hover {
|
||||||
|
color: #111111;
|
||||||
|
background: #e9e9e5;
|
||||||
|
box-shadow: inset 0 -3px #111111;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-header nav a[aria-current="page"]:hover {
|
||||||
|
background: #f2f500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ratio-control label:hover span,
|
||||||
|
.reference-input:hover {
|
||||||
|
border-color: #111111;
|
||||||
|
background: #ffffd6;
|
||||||
|
box-shadow: inset 0 -3px #111111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card:hover {
|
||||||
|
border-color: #111111;
|
||||||
|
box-shadow: 0 3px 0 rgb(17 17 17 / 22%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-asset-tabs button:not(:disabled):hover,
|
||||||
|
.editor-source:hover,
|
||||||
|
.editor-sticker-grid button:not(:disabled):hover,
|
||||||
|
.editor-template-categories button:not(:disabled):hover,
|
||||||
|
.editor-template-grid button:not(:disabled):hover,
|
||||||
|
.editor-provider-grid button:not(:disabled):hover,
|
||||||
|
.editor-candidates button:not(:disabled):hover {
|
||||||
|
border-color: #111111;
|
||||||
|
background: #ffffd6;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(input:not(:disabled), select:not(:disabled), textarea:not(:disabled)):hover {
|
||||||
|
border-color: #111111;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
|
||||||
|
:where(button:not(:disabled), a[href]):hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card:hover .project-preview img,
|
||||||
|
.editor-sticker-grid button:not(:disabled):hover .editor-sticker-preview {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-template-grid button:not(:disabled):hover .editor-template-mark,
|
||||||
|
.editor-provider-grid button:not(:disabled):hover > :first-child {
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
:where(button:not(:disabled), a[href]):active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
transition-duration: 45ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(button:not(:disabled)):active {
|
||||||
|
box-shadow: inset 0 2px 0 rgb(17 17 17 / 24%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(a[href]):active {
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:where(button, a[href], input, select, textarea),
|
||||||
|
.project-card,
|
||||||
|
.project-preview img,
|
||||||
|
.ratio-control span,
|
||||||
|
.reference-input,
|
||||||
|
.editor-sticker-preview,
|
||||||
|
.editor-template-mark,
|
||||||
|
.editor-color-card-preview,
|
||||||
|
.editor-dynamic-preview,
|
||||||
|
.editor-source-preview-canvas,
|
||||||
|
.editor-thumb {
|
||||||
|
animation-duration: 0s !important;
|
||||||
|
transition-duration: 0s !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.
|
|||||||
import { EditorPage } from "./editor-page.js";
|
import { EditorPage } from "./editor-page.js";
|
||||||
import { AdminOverviewPage, AdminPlaceholderPage, AdminProtectedRoute } from "./admin-shell.js";
|
import { AdminOverviewPage, AdminPlaceholderPage, AdminProtectedRoute } from "./admin-shell.js";
|
||||||
|
|
||||||
|
import "./interaction-feedback.css";
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
|
|
||||||
if (!root) {
|
if (!root) {
|
||||||
|
|||||||
@@ -592,15 +592,26 @@
|
|||||||
height: 18px;
|
height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-placeholder {
|
.project-placeholder,
|
||||||
|
.project-preview {
|
||||||
display: grid;
|
display: grid;
|
||||||
height: 154px;
|
height: 154px;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-bottom: 1px solid #a5a59f;
|
border-bottom: 1px solid #a5a59f;
|
||||||
background: #d8d8d3;
|
background: #d8d8d3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-placeholder {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-preview img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.project-placeholder span {
|
.project-placeholder span {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: end center;
|
place-items: end center;
|
||||||
@@ -935,9 +946,9 @@
|
|||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-current > .project-placeholder {
|
.project-current > .project-placeholder,
|
||||||
height: auto;
|
.project-current > .project-preview {
|
||||||
min-height: 480px;
|
height: 480px;
|
||||||
border: 1px solid #73736d;
|
border: 1px solid #73736d;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -945,6 +956,10 @@
|
|||||||
font-size: 80px;
|
font-size: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-current > .project-preview img {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.project-actions {
|
.project-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -1013,7 +1028,8 @@
|
|||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-history li .project-placeholder {
|
.project-history li .project-placeholder,
|
||||||
|
.project-history li .project-preview {
|
||||||
height: 88px;
|
height: 88px;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
@@ -1439,8 +1455,9 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-current > .project-placeholder {
|
.project-current > .project-placeholder,
|
||||||
min-height: 360px;
|
.project-current > .project-preview {
|
||||||
|
height: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.local-only-footer {
|
.local-only-footer {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface LocalDataPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface AccountSettingsPayload {
|
interface AccountSettingsPayload {
|
||||||
|
csrf_token: string;
|
||||||
local_data: LocalDataPayload;
|
local_data: LocalDataPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +232,33 @@ function ProjectPlaceholder({ ratio, status }: { ratio: Ratio; status: ProjectSt
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProjectPreview({ alt, imageId, loading = "lazy", projectId, ratio, status }: {
|
||||||
|
alt: string;
|
||||||
|
imageId: string | null;
|
||||||
|
loading?: "eager" | "lazy";
|
||||||
|
projectId: string;
|
||||||
|
ratio: Ratio;
|
||||||
|
status: ProjectStatus;
|
||||||
|
}) {
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => setLoadFailed(false), [imageId, projectId]);
|
||||||
|
|
||||||
|
if (!imageId || loadFailed) return <ProjectPlaceholder ratio={ratio} status={status} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="project-preview" data-ratio={ratio} data-status={status}>
|
||||||
|
<img
|
||||||
|
alt={alt}
|
||||||
|
decoding="async"
|
||||||
|
loading={loading}
|
||||||
|
onError={() => setLoadFailed(true)}
|
||||||
|
src={`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(imageId)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkspacePage() {
|
export function WorkspacePage() {
|
||||||
const promptId = useId();
|
const promptId = useId();
|
||||||
const [session, setSession] = useState<SessionPayload>();
|
const [session, setSession] = useState<SessionPayload>();
|
||||||
@@ -269,7 +297,12 @@ export function WorkspacePage() {
|
|||||||
if (!active) return;
|
if (!active) return;
|
||||||
if (modelResult.status === "fulfilled") setModels(modelResult.value);
|
if (modelResult.status === "fulfilled") setModels(modelResult.value);
|
||||||
if (taskResult.status === "fulfilled") setCurrentTask(taskResult.value);
|
if (taskResult.status === "fulfilled") setCurrentTask(taskResult.value);
|
||||||
if (settingsResult.status === "fulfilled" && settingsResult.value) setLocalData(settingsResult.value.local_data);
|
const settings = settingsResult.status === "fulfilled" ? settingsResult.value : undefined;
|
||||||
|
if (settings) {
|
||||||
|
setLocalData(settings.local_data);
|
||||||
|
// Account settings rotates the mutation token; keep the workspace token current.
|
||||||
|
setSession((current) => current ? { ...current, csrf_token: settings.csrf_token } : current);
|
||||||
|
}
|
||||||
setGenerationStateLoaded(true);
|
setGenerationStateLoaded(true);
|
||||||
});
|
});
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
@@ -568,7 +601,13 @@ function ProjectCard({ activeLimitReached, busy, onPurge, onRestore, onSelect, o
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
|
<ProjectPreview
|
||||||
|
alt={`${project.name}预览图`}
|
||||||
|
imageId={project.current_image_id}
|
||||||
|
projectId={project.project_id}
|
||||||
|
ratio={project.ratio}
|
||||||
|
status={project.status}
|
||||||
|
/>
|
||||||
<div className="project-card-body">
|
<div className="project-card-body">
|
||||||
<div><h3 title={project.name}>{project.name}</h3><span>{project.status === "failed_empty" ? "生成失败" : project.status === "trashed" ? "回收站" : "项目"}</span></div>
|
<div><h3 title={project.name}>{project.name}</h3><span>{project.status === "failed_empty" ? "生成失败" : project.status === "trashed" ? "回收站" : "项目"}</span></div>
|
||||||
<p>{project.successful_image_count} 张成功图 · {project.ratio}</p>
|
<p>{project.successful_image_count} 张成功图 · {project.ratio}</p>
|
||||||
@@ -953,7 +992,14 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
|
|||||||
<div className="project-detail-grid">
|
<div className="project-detail-grid">
|
||||||
<section className="project-current" aria-labelledby="current-image-title">
|
<section className="project-current" aria-labelledby="current-image-title">
|
||||||
<header><h2 id="current-image-title">当前底图</h2><span>{project.pixel_width ?? 1080} × {project.pixel_height ?? 1440}</span></header>
|
<header><h2 id="current-image-title">当前底图</h2><span>{project.pixel_width ?? 1080} × {project.pixel_height ?? 1440}</span></header>
|
||||||
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
|
<ProjectPreview
|
||||||
|
alt={`${project.name}当前底图`}
|
||||||
|
imageId={project.current_image_id}
|
||||||
|
loading="eager"
|
||||||
|
projectId={project.project_id}
|
||||||
|
ratio={project.ratio}
|
||||||
|
status={project.status}
|
||||||
|
/>
|
||||||
<div className="project-actions">
|
<div className="project-actions">
|
||||||
<button disabled={conflicted || atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button">继续生成</button>
|
<button disabled={conflicted || atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button">继续生成</button>
|
||||||
{conflicted || !project.current_image_id ? <button disabled type="button">进入编辑器</button> : <a href={`/app/projects/${project.project_id}/editor`}>进入编辑器</a>}
|
{conflicted || !project.current_image_id ? <button disabled type="button">进入编辑器</button> : <a href={`/app/projects/${project.project_id}/editor`}>进入编辑器</a>}
|
||||||
@@ -970,7 +1016,13 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
|
|||||||
<ol>
|
<ol>
|
||||||
{project.images.toReversed().map((image, index) => (
|
{project.images.toReversed().map((image, index) => (
|
||||||
<li key={image.image_id} data-current={image.image_id === project.current_image_id}>
|
<li key={image.image_id} data-current={image.image_id === project.current_image_id}>
|
||||||
<ProjectPlaceholder ratio={project.ratio} status="active" />
|
<ProjectPreview
|
||||||
|
alt={`生成结果 ${project.images.length - index}`}
|
||||||
|
imageId={image.image_id}
|
||||||
|
projectId={project.project_id}
|
||||||
|
ratio={project.ratio}
|
||||||
|
status="active"
|
||||||
|
/>
|
||||||
<div><strong>生成结果 {project.images.length - index}</strong><time dateTime={image.created_at}>{formatUpdatedAt(image.created_at)}</time><a href={`/api/v1/private-assets/projects/${project.project_id}/images/${image.image_id}`}>下载原始图</a></div>
|
<div><strong>生成结果 {project.images.length - index}</strong><time dateTime={image.created_at}>{formatUpdatedAt(image.created_at)}</time><a href={`/api/v1/private-assets/projects/${project.project_id}/images/${image.image_id}`}>下载原始图</a></div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export interface GenerationPollingProcessor {
|
||||||
|
processNext(): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenerationPollingLoop {
|
||||||
|
private closed = false;
|
||||||
|
private inFlight = false;
|
||||||
|
private readonly timer: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly processor: GenerationPollingProcessor,
|
||||||
|
intervalMilliseconds = 250,
|
||||||
|
) {
|
||||||
|
if (!Number.isSafeInteger(intervalMilliseconds) || intervalMilliseconds <= 0) {
|
||||||
|
throw new Error("generation_polling_interval_invalid");
|
||||||
|
}
|
||||||
|
this.timer = setInterval(() => this.run(), intervalMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.closed = true;
|
||||||
|
clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private run() {
|
||||||
|
if (this.closed || this.inFlight) return;
|
||||||
|
this.inFlight = true;
|
||||||
|
void this.processor.processNext()
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
this.inFlight = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ const gptImageEndpoint = "https://oneapi.intelligrow.cn/v1/images/generations";
|
|||||||
const gptImageReferenceEndpoint = "https://oneapi.intelligrow.cn/v1/images/edits";
|
const gptImageReferenceEndpoint = "https://oneapi.intelligrow.cn/v1/images/edits";
|
||||||
const maximumResponseBytes = 32 * 1024 * 1024;
|
const maximumResponseBytes = 32 * 1024 * 1024;
|
||||||
const requestTimeoutMilliseconds = 180_000;
|
const requestTimeoutMilliseconds = 180_000;
|
||||||
|
const geminiImageSystemInstruction = "Generate exactly one image from the user's description. Return the generated image and do not answer with text only.";
|
||||||
|
|
||||||
type FetchLike = typeof fetch;
|
type FetchLike = typeof fetch;
|
||||||
|
|
||||||
@@ -141,7 +142,10 @@ function buildRequest(request: GenerationAdapterRequest) {
|
|||||||
return {
|
return {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
extra_body: { google: { image_config: { aspect_ratio: request.ratio, image_size: "1K" } } },
|
extra_body: { google: { image_config: { aspect_ratio: request.ratio, image_size: "1K" } } },
|
||||||
messages: [{ content, role: "user" }],
|
messages: [
|
||||||
|
{ content: geminiImageSystemInstruction, role: "system" },
|
||||||
|
{ content, role: "user" },
|
||||||
|
],
|
||||||
model: geminiProviderModelId,
|
model: geminiProviderModelId,
|
||||||
stream: false,
|
stream: false,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
import { WorkerAiCallGate } from "./ai-call-gate.js";
|
import { WorkerAiCallGate } from "./ai-call-gate.js";
|
||||||
import { runAiRuntimeProbe } from "./ai-runtime-probe.js";
|
import { runAiRuntimeProbe } from "./ai-runtime-probe.js";
|
||||||
|
import { GenerationPollingLoop } from "./generation-polling-loop.js";
|
||||||
import { GenerationProcessor } from "./generation-processor.js";
|
import { GenerationProcessor } from "./generation-processor.js";
|
||||||
import { OneApiGenerationAdapter } from "./oneapi-generation-adapter.js";
|
import { OneApiGenerationAdapter } from "./oneapi-generation-adapter.js";
|
||||||
import { readConfiguredLocalDataRoot } from "./runtime-config.js";
|
import { readConfiguredLocalDataRoot } from "./runtime-config.js";
|
||||||
@@ -52,13 +53,13 @@ if (!workerPort && process.argv.includes("--dada-ai-probe")) {
|
|||||||
let projectCleanup: ProjectPurgeCleanup | undefined;
|
let projectCleanup: ProjectPurgeCleanup | undefined;
|
||||||
let retentionTimer: ReturnType<typeof setInterval> | undefined;
|
let retentionTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
let processor: GenerationProcessor | undefined;
|
let processor: GenerationProcessor | undefined;
|
||||||
let generationTimer: ReturnType<typeof setInterval> | undefined;
|
let generationLoop: GenerationPollingLoop | undefined;
|
||||||
const control = attachWorkerSupervisorControl(controlPipe, () => {
|
const control = attachWorkerSupervisorControl(controlPipe, () => {
|
||||||
clearInterval(keepAlive);
|
clearInterval(keepAlive);
|
||||||
if (retentionTimer) clearInterval(retentionTimer);
|
if (retentionTimer) clearInterval(retentionTimer);
|
||||||
retention?.close();
|
retention?.close();
|
||||||
projectCleanup?.close();
|
projectCleanup?.close();
|
||||||
if (generationTimer) clearInterval(generationTimer);
|
generationLoop?.close();
|
||||||
processor?.close();
|
processor?.close();
|
||||||
storage?.close();
|
storage?.close();
|
||||||
});
|
});
|
||||||
@@ -102,7 +103,7 @@ if (!workerPort && process.argv.includes("--dada-ai-probe")) {
|
|||||||
});
|
});
|
||||||
logger.write({ error_category: "none", status_category: "ready" });
|
logger.write({ error_category: "none", status_category: "ready" });
|
||||||
new WorkerAiCallGate({ getStorageStatus: () => storageStatus === "unavailable" ? storageStatus : (storage?.getStatus() ?? "unavailable"), logger });
|
new WorkerAiCallGate({ getStorageStatus: () => storageStatus === "unavailable" ? storageStatus : (storage?.getStatus() ?? "unavailable"), logger });
|
||||||
generationTimer = setInterval(() => { void processor?.processNext().catch(() => undefined); }, 250);
|
generationLoop = new GenerationPollingLoop(processor);
|
||||||
} catch {
|
} catch {
|
||||||
storageStatus = "unavailable";
|
storageStatus = "unavailable";
|
||||||
control.reportStatus("storage_unavailable");
|
control.reportStatus("storage_unavailable");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,8 @@
|
|||||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
||||||
"test:package": "pnpm build:workspace-packages && pnpm run typecheck && node --test tests/package/wp0-09-portable.test.mjs && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs",
|
"test:package": "pnpm build:workspace-packages && pnpm run typecheck && node --test tests/package/wp0-09-portable.test.mjs && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs",
|
||||||
"package:portable": "node scripts/build-portable.mjs",
|
"package:portable": "node scripts/build-portable.mjs",
|
||||||
|
"assets:manifest": "pnpm build:workspace-packages && node scripts/generate-runtime-asset-manifest.mjs",
|
||||||
|
"assets:deploy": "pnpm build:workspace-packages && node scripts/deploy-runtime-assets.mjs",
|
||||||
"generate:openapi": "node scripts/generate-openapi.mjs",
|
"generate:openapi": "node scripts/generate-openapi.mjs",
|
||||||
"check:openapi": "node scripts/check-openapi.mjs",
|
"check:openapi": "node scripts/check-openapi.mjs",
|
||||||
"validate:tdd-trace": "node scripts/validate-tdd-trace.mjs",
|
"validate:tdd-trace": "node scripts/validate-tdd-trace.mjs",
|
||||||
|
|||||||
@@ -23,6 +23,27 @@ export const P0A_DYNAMIC_STICKER_IDS = [
|
|||||||
"DYN008", "DYN011", "DYN012", "DYN015", "DYN016",
|
"DYN008", "DYN011", "DYN012", "DYN015", "DYN016",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const P0A_DYNAMIC_RUNTIME_FONT_SOURCES = [
|
||||||
|
{ assetId: "15974853bc3294ef68e7e6d58fe74fd7", sourceReference: "fonts/15974853bc3294ef68e7e6d58fe74fd7", templateId: "DYN002" },
|
||||||
|
{ assetId: "46f8336813e4c48d06a1aef294fdccf6", sourceReference: "fonts/46f8336813e4c48d06a1aef294fdccf6", templateId: "DYN016" },
|
||||||
|
{ assetId: "53ca6b704728520da50c145eabb2e635", sourceReference: "fonts/53ca6b704728520da50c145eabb2e635", templateId: "DYN007" },
|
||||||
|
{ assetId: "cca5efc0e02fb1bf62349bd68ef30fc1", sourceReference: "fonts/cca5efc0e02fb1bf62349bd68ef30fc1", templateId: "DYN015" },
|
||||||
|
{ assetId: "dd25b35dcb7ba4476cbaa9a9592e39e2", sourceReference: "fonts/dd25b35dcb7ba4476cbaa9a9592e39e2", templateId: "DYN001" },
|
||||||
|
{ assetId: "e4210c9872f0c279b35273f230809821", sourceReference: "fonts/e4210c9872f0c279b35273f230809821", templateId: "DYN011" },
|
||||||
|
{ assetId: "f4bfd4132df2d6be97ceabadf3853505", sourceReference: "fonts/f4bfd4132df2d6be97ceabadf3853505", templateId: "DYN008" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES = [
|
||||||
|
{ assetId: "DYN001-image28", sourceReference: "resource/image28.png", templateId: "DYN001" },
|
||||||
|
{ assetId: "DYN002-image29", sourceReference: "resource/image29.png", templateId: "DYN002" },
|
||||||
|
{ assetId: "DYN003-image30", sourceReference: "resource/image30.png", templateId: "DYN003" },
|
||||||
|
{ assetId: "DYN004-image32", sourceReference: "resource/image32.png", templateId: "DYN004" },
|
||||||
|
{ assetId: "DYN008-backendui0", sourceReference: "resource/backendui0.png", templateId: "DYN008" },
|
||||||
|
{ assetId: "DYN011-backendui0", sourceReference: "resource/backendui0.png", templateId: "DYN011" },
|
||||||
|
{ assetId: "DYN015-imager2", sourceReference: "resource/imager2_2.png", templateId: "DYN015" },
|
||||||
|
{ assetId: "DYN016-image21", sourceReference: "resource/image21.png", templateId: "DYN016" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export type RegisteredComplexFamily = "color_card" | "font_panel" | "interactive_sticker" | "text_template";
|
export type RegisteredComplexFamily = "color_card" | "font_panel" | "interactive_sticker" | "text_template";
|
||||||
|
|
||||||
export interface RegisteredComplexAsset extends Record<string, unknown> {
|
export interface RegisteredComplexAsset extends Record<string, unknown> {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { isAbsolute, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildP0aRuntimeAssetPlan,
|
||||||
|
defaultReplicationRoot,
|
||||||
|
deployRuntimeAssetPlan,
|
||||||
|
readRuntimeAssetManifest,
|
||||||
|
serializeRuntimeAssetManifest,
|
||||||
|
} from "./lib/runtime-assets.mjs";
|
||||||
|
|
||||||
|
function option(name) {
|
||||||
|
const index = process.argv.indexOf(name);
|
||||||
|
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultConfigPath() {
|
||||||
|
if (!process.env.LOCALAPPDATA || !isAbsolute(process.env.LOCALAPPDATA)) throw new Error("local_app_data_unavailable");
|
||||||
|
return join(process.env.LOCALAPPDATA, "Dada", "P0A", "config", "instance.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
const configFile = resolve(option("--config") ?? process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultConfigPath());
|
||||||
|
const configuration = JSON.parse(readFileSync(configFile, "utf8"));
|
||||||
|
const assetRootCandidate = option("--asset-root") ?? configuration.asset_root;
|
||||||
|
if (typeof assetRootCandidate !== "string" || !isAbsolute(assetRootCandidate)) {
|
||||||
|
throw new Error("asset_root_configuration_invalid");
|
||||||
|
}
|
||||||
|
const assetRoot = resolve(assetRootCandidate);
|
||||||
|
const trustedManifest = readRuntimeAssetManifest(resolve(option("--trusted-manifest") ?? "config/runtime-assets-manifest.json"));
|
||||||
|
const plan = await buildP0aRuntimeAssetPlan({
|
||||||
|
replicationRoot: resolve(option("--replication-root") ?? defaultReplicationRoot()),
|
||||||
|
});
|
||||||
|
if (serializeRuntimeAssetManifest(plan.manifest) !== serializeRuntimeAssetManifest(trustedManifest)) {
|
||||||
|
throw new Error("runtime_asset_source_does_not_match_trusted_manifest");
|
||||||
|
}
|
||||||
|
const result = deployRuntimeAssetPlan({ assetRoot, manifest: trustedManifest, resources: plan.resources });
|
||||||
|
process.stdout.write(`${JSON.stringify({ linked_files: result.linked_files, status: result.status })}\n`);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildP0aRuntimeAssetPlan,
|
||||||
|
defaultReplicationRoot,
|
||||||
|
serializeRuntimeAssetManifest,
|
||||||
|
writeRuntimeAssetManifest,
|
||||||
|
} from "./lib/runtime-assets.mjs";
|
||||||
|
|
||||||
|
function option(name) {
|
||||||
|
const index = process.argv.indexOf(name);
|
||||||
|
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replicationRoot = resolve(option("--replication-root") ?? defaultReplicationRoot());
|
||||||
|
const outputPath = resolve(option("--output") ?? "config/runtime-assets-manifest.json");
|
||||||
|
const plan = await buildP0aRuntimeAssetPlan({ replicationRoot });
|
||||||
|
writeRuntimeAssetManifest(outputPath, plan.manifest);
|
||||||
|
process.stdout.write(`${JSON.stringify({
|
||||||
|
counts: plan.manifest.counts,
|
||||||
|
manifest_bytes: Buffer.byteLength(serializeRuntimeAssetManifest(plan.manifest)),
|
||||||
|
status: "generated",
|
||||||
|
})}\n`);
|
||||||
@@ -18,6 +18,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
||||||
|
|
||||||
import { frozenRuntime } from "../frozen-versions.mjs";
|
import { frozenRuntime } from "../frozen-versions.mjs";
|
||||||
|
import { readRuntimeAssetManifest } from "./runtime-assets.mjs";
|
||||||
|
|
||||||
const repositoryRoot = resolve(import.meta.dirname, "..", "..");
|
const repositoryRoot = resolve(import.meta.dirname, "..", "..");
|
||||||
const fixedPort = 43121;
|
const fixedPort = 43121;
|
||||||
@@ -363,7 +364,10 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu
|
|||||||
copyTree(join(repositoryRoot, "apps", "web", "dist"), join(packageDirectory, "web"));
|
copyTree(join(repositoryRoot, "apps", "web", "dist"), join(packageDirectory, "web"));
|
||||||
copyTree(join(repositoryRoot, "apps", "web", "support-gate"), join(packageDirectory, "web", "support-gate"));
|
copyTree(join(repositoryRoot, "apps", "web", "support-gate"), join(packageDirectory, "web", "support-gate"));
|
||||||
writeJson(join(packageDirectory, "migrations", "manifest.json"), { migrations: [], schema_version: "0" });
|
writeJson(join(packageDirectory, "migrations", "manifest.json"), { migrations: [], schema_version: "0" });
|
||||||
writeJson(join(packageDirectory, "asset-metadata", "manifest.json"), { resources: [], schema_version: "1.0", source: "external_read_only" });
|
writeJson(
|
||||||
|
join(packageDirectory, "asset-metadata", "manifest.json"),
|
||||||
|
readRuntimeAssetManifest(join(repositoryRoot, "config", "runtime-assets-manifest.json")),
|
||||||
|
);
|
||||||
writeJson(join(packageDirectory, "LICENSES", "third-party.json"), { api: apiDependencies, runtime: { node: frozenRuntime.node }, schema_version: "1.0", worker: workerDependencies });
|
writeJson(join(packageDirectory, "LICENSES", "third-party.json"), { api: apiDependencies, runtime: { node: frozenRuntime.node }, schema_version: "1.0", worker: workerDependencies });
|
||||||
|
|
||||||
const commit = run("git", ["rev-parse", "HEAD"]);
|
const commit = run("git", ["rev-parse", "HEAD"]);
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
linkSync,
|
||||||
|
lstatSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
realpathSync,
|
||||||
|
rmSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
|
||||||
|
|
||||||
|
export const P0A_RUNTIME_ASSET_ROOT_REF = "p0a_runtime_assets";
|
||||||
|
export const RUNTIME_ASSET_MANIFEST_SCHEMA = "DadaRuntimeAssets/v1";
|
||||||
|
|
||||||
|
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
|
||||||
|
const mimePattern = /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i;
|
||||||
|
const releasePattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;
|
||||||
|
const shaPattern = /^[a-f0-9]{64}$/i;
|
||||||
|
const fontMimeTypes = new Map([
|
||||||
|
[".otf", "font/otf"],
|
||||||
|
[".ttf", "font/ttf"],
|
||||||
|
[".woff", "font/woff"],
|
||||||
|
[".woff2", "font/woff2"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
function sha256(bytes) {
|
||||||
|
return createHash("sha256").update(bytes).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileSha256(path) {
|
||||||
|
return sha256(readFileSync(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableEntries(entries) {
|
||||||
|
return entries.map((entry) => {
|
||||||
|
if (!entry || typeof entry !== "object") throw new Error("runtime_asset_entry_invalid");
|
||||||
|
if (!assetIdPattern.test(entry.assetId)) throw new Error("runtime_asset_id_invalid");
|
||||||
|
if (!mimePattern.test(entry.mimeType)) throw new Error("runtime_asset_mime_invalid");
|
||||||
|
if (!releasePattern.test(entry.resourceVersion)) throw new Error("runtime_asset_version_invalid");
|
||||||
|
if (entry.rootRef !== P0A_RUNTIME_ASSET_ROOT_REF) throw new Error("runtime_asset_root_ref_invalid");
|
||||||
|
if (!shaPattern.test(entry.sha256)) throw new Error("runtime_asset_sha256_invalid");
|
||||||
|
if (
|
||||||
|
typeof entry.relativePath !== "string"
|
||||||
|
|| isAbsolute(entry.relativePath)
|
||||||
|
|| entry.relativePath.includes("\\")
|
||||||
|
|| entry.relativePath.split("/").some((part) => part === "" || part === "..")
|
||||||
|
) throw new Error("runtime_asset_relative_path_invalid");
|
||||||
|
return { ...entry, sha256: entry.sha256.toLowerCase() };
|
||||||
|
}).sort((left, right) => {
|
||||||
|
const byVersion = left.resourceVersion.localeCompare(right.resourceVersion);
|
||||||
|
return byVersion || left.assetId.localeCompare(right.assetId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function derivedCounts(entries) {
|
||||||
|
return {
|
||||||
|
dynamic_fonts: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^[a-f0-9]{32}$/.test(entry.assetId)).length,
|
||||||
|
dynamic_images: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^DYN\d{3}-/.test(entry.assetId)).length,
|
||||||
|
font_panel_items: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^FONT\d{3}$/.test(entry.assetId)).length,
|
||||||
|
static_stickers: entries.filter((entry) => entry.resourceVersion === "p0a-static-v1" && /^STK\d{3,4}$/.test(entry.assetId)).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRuntimeAssetManifest({ counts, entries, sourceManifestSha256 }) {
|
||||||
|
const normalizedEntries = stableEntries(entries);
|
||||||
|
const keys = new Set();
|
||||||
|
const paths = new Set();
|
||||||
|
for (const entry of normalizedEntries) {
|
||||||
|
const key = `${entry.resourceVersion}\u0000${entry.assetId}`;
|
||||||
|
if (keys.has(key)) throw new Error("runtime_asset_id_duplicate");
|
||||||
|
if (paths.has(entry.relativePath)) throw new Error("runtime_asset_path_duplicate");
|
||||||
|
keys.add(key);
|
||||||
|
paths.add(entry.relativePath);
|
||||||
|
}
|
||||||
|
const actualCounts = derivedCounts(normalizedEntries);
|
||||||
|
if (JSON.stringify(counts) !== JSON.stringify(actualCounts)) throw new Error("runtime_asset_counts_invalid");
|
||||||
|
if (sourceManifestSha256 !== undefined && !shaPattern.test(sourceManifestSha256)) {
|
||||||
|
throw new Error("runtime_asset_source_manifest_sha256_invalid");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
counts: actualCounts,
|
||||||
|
entries: normalizedEntries,
|
||||||
|
root_ref: P0A_RUNTIME_ASSET_ROOT_REF,
|
||||||
|
schema_version: RUNTIME_ASSET_MANIFEST_SCHEMA,
|
||||||
|
source: "external_read_only",
|
||||||
|
...(sourceManifestSha256 ? { source_manifest_sha256: sourceManifestSha256.toLowerCase() } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readRuntimeAssetManifest(path) {
|
||||||
|
const value = JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
if (
|
||||||
|
value?.schema_version !== RUNTIME_ASSET_MANIFEST_SCHEMA
|
||||||
|
|| value?.source !== "external_read_only"
|
||||||
|
|| value?.root_ref !== P0A_RUNTIME_ASSET_ROOT_REF
|
||||||
|
|| !Array.isArray(value.entries)
|
||||||
|
) throw new Error("runtime_asset_manifest_invalid");
|
||||||
|
return createRuntimeAssetManifest({
|
||||||
|
counts: value.counts,
|
||||||
|
entries: value.entries,
|
||||||
|
...(value.source_manifest_sha256 ? { sourceManifestSha256: value.source_manifest_sha256 } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeRuntimeAssetManifest(manifest) {
|
||||||
|
return `${JSON.stringify(manifest, null, 2)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeRuntimeAssetManifest(path, manifest) {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
writeFileSync(path, serializeRuntimeAssetManifest(manifest));
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetWithinRoot(root, relativePath) {
|
||||||
|
const absoluteRoot = resolve(root);
|
||||||
|
const target = resolve(absoluteRoot, ...relativePath.split("/"));
|
||||||
|
if (target === absoluteRoot || !target.startsWith(`${absoluteRoot}${sep}`)) throw new Error("asset_target_path_invalid");
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameFile(left, right) {
|
||||||
|
const leftStat = statSync(left);
|
||||||
|
const rightStat = statSync(right);
|
||||||
|
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deployRuntimeAssetPlan({ assetRoot, manifest, resources }) {
|
||||||
|
if (!isAbsolute(assetRoot)) throw new Error("asset_root_must_be_absolute");
|
||||||
|
const normalizedManifest = createRuntimeAssetManifest({
|
||||||
|
counts: manifest.counts,
|
||||||
|
entries: manifest.entries,
|
||||||
|
...(manifest.source_manifest_sha256 ? { sourceManifestSha256: manifest.source_manifest_sha256 } : {}),
|
||||||
|
});
|
||||||
|
const entries = new Map(normalizedManifest.entries.map((entry) => [`${entry.resourceVersion}\u0000${entry.assetId}`, entry]));
|
||||||
|
if (resources.length !== entries.size) throw new Error("asset_resource_plan_incomplete");
|
||||||
|
mkdirSync(assetRoot, { recursive: true });
|
||||||
|
for (const resource of resources) {
|
||||||
|
const key = `${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`;
|
||||||
|
const entry = entries.get(key);
|
||||||
|
if (!entry || JSON.stringify(entry) !== JSON.stringify({ ...resource.entry, sha256: resource.entry.sha256.toLowerCase() })) {
|
||||||
|
throw new Error("asset_resource_plan_mismatch");
|
||||||
|
}
|
||||||
|
if (!existsSync(resource.sourcePath) || !statSync(resource.sourcePath).isFile() || lstatSync(resource.sourcePath).isSymbolicLink()) {
|
||||||
|
throw new Error("asset_source_invalid");
|
||||||
|
}
|
||||||
|
if (fileSha256(resource.sourcePath) !== entry.sha256) throw new Error("asset_source_hash_invalid");
|
||||||
|
const targetPath = targetWithinRoot(assetRoot, entry.relativePath);
|
||||||
|
mkdirSync(dirname(targetPath), { recursive: true });
|
||||||
|
if (existsSync(targetPath)) {
|
||||||
|
if (fileSha256(targetPath) !== entry.sha256) throw new Error("asset_target_conflict");
|
||||||
|
if (!sameFile(resource.sourcePath, targetPath)) throw new Error("asset_target_not_hardlink");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
linkSync(resource.sourcePath, targetPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === "object" && "code" in error && error.code === "EXDEV") {
|
||||||
|
throw new Error("asset_hardlink_volume_mismatch");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!sameFile(resource.sourcePath, targetPath)) throw new Error("asset_hardlink_verification_failed");
|
||||||
|
}
|
||||||
|
writeRuntimeAssetManifest(join(assetRoot, "manifest.json"), normalizedManifest);
|
||||||
|
return { linked_files: resources.length, manifest: normalizedManifest, status: "ready" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneDirectoryWithPrefix(root, prefix) {
|
||||||
|
const matches = readdirSync(root, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory() && entry.name.startsWith(`${prefix}_`));
|
||||||
|
if (matches.length !== 1) throw new Error(`runtime_asset_source_directory_invalid:${prefix}`);
|
||||||
|
return join(root, matches[0].name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneSupportedFont(root) {
|
||||||
|
const matches = readdirSync(root, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isFile() && fontMimeTypes.has(extname(entry.name).toLowerCase()));
|
||||||
|
if (matches.length !== 1) throw new Error(`runtime_font_source_invalid:${basename(root)}`);
|
||||||
|
return join(root, matches[0].name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryFor(sourcePath, assetId, resourceVersion, relativePath, mimeType) {
|
||||||
|
return {
|
||||||
|
assetId,
|
||||||
|
mimeType,
|
||||||
|
relativePath,
|
||||||
|
resourceVersion,
|
||||||
|
rootRef: P0A_RUNTIME_ASSET_ROOT_REF,
|
||||||
|
sha256: fileSha256(sourcePath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dynamicMetadata(templateRoot, descriptor, field) {
|
||||||
|
const templateDirectory = join(templateRoot, descriptor.templateId);
|
||||||
|
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
|
||||||
|
if (!Array.isArray(metadata?.files?.[field]) || !metadata.files[field].includes(descriptor.sourceReference)) {
|
||||||
|
throw new Error(`runtime_dynamic_reference_invalid:${descriptor.assetId}`);
|
||||||
|
}
|
||||||
|
return templateDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
||||||
|
const [{ compileStaticStickerCatalog }, registry] = await Promise.all([
|
||||||
|
import("../../packages/asset-compiler/dist/index.js"),
|
||||||
|
import("../../packages/template-registry/dist/index.js"),
|
||||||
|
]);
|
||||||
|
const compilerOutput = mkdtempSync(join(tmpdir(), "dada-runtime-asset-plan-"));
|
||||||
|
try {
|
||||||
|
const staticSourceRoot = join(replicationRoot, "sticker_normal");
|
||||||
|
const staticResult = compileStaticStickerCatalog({
|
||||||
|
outputDirectory: compilerOutput,
|
||||||
|
releaseVersion: registry.P0A_STATIC_STICKER_RELEASE_VERSION,
|
||||||
|
sourceRoot: staticSourceRoot,
|
||||||
|
});
|
||||||
|
const resources = staticResult.catalog.items.map((item) => {
|
||||||
|
const sourcePath = join(staticSourceRoot, ...item.relative_path.split("/"));
|
||||||
|
const entry = entryFor(
|
||||||
|
sourcePath,
|
||||||
|
item.stable_id,
|
||||||
|
registry.P0A_STATIC_STICKER_RELEASE_VERSION,
|
||||||
|
`${registry.P0A_STATIC_STICKER_RELEASE_VERSION}/${item.stable_id}.png`,
|
||||||
|
"image/png",
|
||||||
|
);
|
||||||
|
if (entry.sha256 !== item.sha256.toLowerCase()) throw new Error(`static_sticker_hash_invalid:${item.stable_id}`);
|
||||||
|
return { entry, sourcePath };
|
||||||
|
});
|
||||||
|
|
||||||
|
const fontPackagesRoot = join(
|
||||||
|
replicationRoot,
|
||||||
|
"sticker_text",
|
||||||
|
"字体",
|
||||||
|
"面板全量采集",
|
||||||
|
"font_panel_full_20260722",
|
||||||
|
"resources",
|
||||||
|
"font_packages",
|
||||||
|
);
|
||||||
|
for (const assetId of registry.P0A_REQUIRED_FONT_PANEL_IDS) {
|
||||||
|
const packageDirectory = oneDirectoryWithPrefix(fontPackagesRoot, assetId);
|
||||||
|
const sourcePath = oneSupportedFont(join(packageDirectory, "font_files"));
|
||||||
|
const extension = extname(sourcePath).toLowerCase();
|
||||||
|
resources.push({
|
||||||
|
entry: entryFor(
|
||||||
|
sourcePath,
|
||||||
|
assetId,
|
||||||
|
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||||
|
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}${extension}`,
|
||||||
|
fontMimeTypes.get(extension),
|
||||||
|
),
|
||||||
|
sourcePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateRoot = join(replicationRoot, "sticker_interactive", "单模板归档", "templates");
|
||||||
|
for (const descriptor of registry.P0A_DYNAMIC_RUNTIME_FONT_SOURCES) {
|
||||||
|
const templateDirectory = dynamicMetadata(templateRoot, descriptor, "fonts");
|
||||||
|
const sourcePath = oneSupportedFont(join(templateDirectory, ...descriptor.sourceReference.split("/")));
|
||||||
|
const extension = extname(sourcePath).toLowerCase();
|
||||||
|
resources.push({
|
||||||
|
entry: entryFor(
|
||||||
|
sourcePath,
|
||||||
|
descriptor.assetId,
|
||||||
|
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||||
|
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${descriptor.assetId}${extension}`,
|
||||||
|
fontMimeTypes.get(extension),
|
||||||
|
),
|
||||||
|
sourcePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const descriptor of registry.P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES) {
|
||||||
|
const templateDirectory = dynamicMetadata(templateRoot, descriptor, "images");
|
||||||
|
const sourcePath = join(templateDirectory, ...descriptor.sourceReference.split("/"));
|
||||||
|
if (!existsSync(sourcePath) || extname(sourcePath).toLowerCase() !== ".png") {
|
||||||
|
throw new Error(`runtime_dynamic_image_invalid:${descriptor.assetId}`);
|
||||||
|
}
|
||||||
|
resources.push({
|
||||||
|
entry: entryFor(
|
||||||
|
sourcePath,
|
||||||
|
descriptor.assetId,
|
||||||
|
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||||
|
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${descriptor.assetId}.png`,
|
||||||
|
"image/png",
|
||||||
|
),
|
||||||
|
sourcePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestPath = join(replicationRoot, "sticker_web_handoff", "sticker_web_catalog_manifest.json");
|
||||||
|
const manifest = createRuntimeAssetManifest({
|
||||||
|
counts: {
|
||||||
|
dynamic_fonts: registry.P0A_DYNAMIC_RUNTIME_FONT_SOURCES.length,
|
||||||
|
dynamic_images: registry.P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES.length,
|
||||||
|
font_panel_items: registry.P0A_REQUIRED_FONT_PANEL_IDS.length,
|
||||||
|
static_stickers: staticResult.catalog.count,
|
||||||
|
},
|
||||||
|
entries: resources.map((resource) => resource.entry),
|
||||||
|
sourceManifestSha256: fileSha256(manifestPath),
|
||||||
|
});
|
||||||
|
const resourcesByKey = new Map(resources.map((resource) => [`${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`, resource]));
|
||||||
|
return {
|
||||||
|
manifest,
|
||||||
|
resources: manifest.entries.map((entry) => resourcesByKey.get(`${entry.resourceVersion}\u0000${entry.assetId}`)),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
rmSync(compilerOutput, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultReplicationRoot(environment = process.env) {
|
||||||
|
if (!environment.USERPROFILE || !isAbsolute(environment.USERPROFILE)) throw new Error("user_profile_unavailable");
|
||||||
|
return join(environment.USERPROFILE, "Desktop", "sticker_web_replication_assets");
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("POSTV1-11 workspace generation CSRF refresh", () => {
|
||||||
|
it("keeps the rotated account-settings token for generation submission", () => {
|
||||||
|
const source = readFileSync("apps/web/src/project-pages.tsx", "utf8");
|
||||||
|
|
||||||
|
expect(source).toContain("csrf_token: string;");
|
||||||
|
expect(source).toContain("csrf_token: settings.csrf_token");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { GenerationModelConfigurationCatalog, type ModelConfigurationView } from "../../apps/api/src/model-configuration.js";
|
||||||
|
|
||||||
|
describe("POSTV1-04 generation runtime wiring", () => {
|
||||||
|
it("constructs, injects and closes the production generation submission service", () => {
|
||||||
|
const main = readFileSync("apps/api/src/main.ts", "utf8");
|
||||||
|
|
||||||
|
expect(main).toContain('import { GenerationSubmissionService } from "./generation-submission.js";');
|
||||||
|
expect(main).toContain("let generations: GenerationSubmissionService | undefined;");
|
||||||
|
expect(main).toContain("generations = new GenerationSubmissionService({");
|
||||||
|
expect(main).toContain("models: new GenerationModelConfigurationCatalog(models),");
|
||||||
|
expect(main).toContain("...(generations ? { generations } : {}),");
|
||||||
|
expect(main).toContain("generations?.close();");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the current model configuration into the generation submission contract", () => {
|
||||||
|
const configuration: ModelConfigurationView = {
|
||||||
|
config_set_version: 7,
|
||||||
|
configured_default_model_id: "gemini-3.1-flash-image-preview",
|
||||||
|
recommended_model_id: "gemini-3.1-flash-image-preview",
|
||||||
|
models: [{
|
||||||
|
config_version: 3,
|
||||||
|
contract_evidence_ref: "fixture-contract",
|
||||||
|
contract_validation_status: "verified",
|
||||||
|
credit_cost: 2,
|
||||||
|
display_name: "Fixture model",
|
||||||
|
enabled: true,
|
||||||
|
error_mapping_profile: {},
|
||||||
|
gateway_account_ref: "fixture-gateway",
|
||||||
|
is_default: true,
|
||||||
|
model_id: "gemini-3.1-flash-image-preview",
|
||||||
|
prompt_max_length: 1_000,
|
||||||
|
recommendation_priority: 1,
|
||||||
|
reference_limits: { max_file_bytes: 10, max_files: 2, max_total_bytes: 20 },
|
||||||
|
route_profile: {},
|
||||||
|
runtime_availability: { available_for_new_jobs: true, checked_at: "2026-08-05T00:00:00.000Z", reason: "available" },
|
||||||
|
safety_source: "provider",
|
||||||
|
supported_ratios: ["3:4", "invalid"],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const catalog = new GenerationModelConfigurationCatalog({ read: () => configuration });
|
||||||
|
|
||||||
|
expect(catalog.readModel("gemini-3.1-flash-image-preview")).toEqual({
|
||||||
|
configSetVersion: 7,
|
||||||
|
configVersion: 3,
|
||||||
|
contractValidationStatus: "verified",
|
||||||
|
creditCost: 2,
|
||||||
|
enabled: true,
|
||||||
|
modelId: "gemini-3.1-flash-image-preview",
|
||||||
|
promptMaxLength: 1_000,
|
||||||
|
referenceLimits: { maxFileBytes: 10, maxFiles: 2, maxTotalBytes: 20 },
|
||||||
|
runtimeAvailability: { availableForNewJobs: true, reason: null },
|
||||||
|
supportedRatios: ["3:4"],
|
||||||
|
});
|
||||||
|
expect(catalog.readModel("missing")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps model and contract blocks into generation error categories", () => {
|
||||||
|
const configuration = {
|
||||||
|
config_set_version: 1,
|
||||||
|
configured_default_model_id: "gemini-3.1-flash-image-preview",
|
||||||
|
recommended_model_id: null,
|
||||||
|
models: [],
|
||||||
|
} satisfies ModelConfigurationView;
|
||||||
|
const catalog = new GenerationModelConfigurationCatalog({ read: () => configuration });
|
||||||
|
const base = {
|
||||||
|
config_version: 1,
|
||||||
|
contract_evidence_ref: null,
|
||||||
|
contract_validation_status: "verified" as const,
|
||||||
|
credit_cost: 1,
|
||||||
|
display_name: "Fixture model",
|
||||||
|
enabled: true,
|
||||||
|
error_mapping_profile: {},
|
||||||
|
gateway_account_ref: "fixture-gateway",
|
||||||
|
is_default: true,
|
||||||
|
model_id: "gemini-3.1-flash-image-preview" as const,
|
||||||
|
prompt_max_length: 1_000,
|
||||||
|
recommendation_priority: 1,
|
||||||
|
reference_limits: { max_file_bytes: 10, max_files: 2, max_total_bytes: 20 },
|
||||||
|
route_profile: {},
|
||||||
|
safety_source: "provider",
|
||||||
|
supported_ratios: ["3:4"],
|
||||||
|
};
|
||||||
|
|
||||||
|
configuration.models = [{
|
||||||
|
...base,
|
||||||
|
runtime_availability: { available_for_new_jobs: false, checked_at: "2026-08-05T00:00:00.000Z", reason: "contract_blocked" },
|
||||||
|
}];
|
||||||
|
expect(catalog.readModel(base.model_id)?.runtimeAvailability.reason).toBe("gateway_contract_invalid");
|
||||||
|
|
||||||
|
configuration.models = [{
|
||||||
|
...base,
|
||||||
|
runtime_availability: { available_for_new_jobs: false, checked_at: "2026-08-05T00:00:00.000Z", reason: "worker_degraded" },
|
||||||
|
}];
|
||||||
|
expect(catalog.readModel(base.model_id)?.runtimeAvailability.reason).toBe("model_disabled");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { loadConfiguredRuntimeAssets } from "../../apps/api/src/runtime-assets.js";
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const directory of temporaryDirectories.splice(0)) {
|
||||||
|
if (resolve(directory).startsWith(resolve(tmpdir()))) rmSync(directory, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const base = mkdtempSync(join(tmpdir(), "dada-postv1-assets-"));
|
||||||
|
temporaryDirectories.push(base);
|
||||||
|
const assetRoot = join(base, "assets");
|
||||||
|
const dataRoot = join(base, "data");
|
||||||
|
const configFile = join(base, "instance.json");
|
||||||
|
const trustedManifestPath = join(base, "trusted-manifest.json");
|
||||||
|
const bytes = Buffer.from("synthetic sticker bytes");
|
||||||
|
const entry = {
|
||||||
|
assetId: "STK001",
|
||||||
|
mimeType: "image/png",
|
||||||
|
relativePath: "p0a-static-v1/STK001.png",
|
||||||
|
resourceVersion: "p0a-static-v1",
|
||||||
|
rootRef: "p0a_runtime_assets",
|
||||||
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||||
|
};
|
||||||
|
const manifest = {
|
||||||
|
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||||
|
entries: [entry],
|
||||||
|
root_ref: "p0a_runtime_assets",
|
||||||
|
schema_version: "DadaRuntimeAssets/v1",
|
||||||
|
source: "external_read_only",
|
||||||
|
};
|
||||||
|
mkdirSync(join(assetRoot, "p0a-static-v1"), { recursive: true });
|
||||||
|
writeFileSync(join(assetRoot, entry.relativePath), bytes);
|
||||||
|
writeFileSync(join(assetRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
writeFileSync(trustedManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
writeFileSync(configFile, JSON.stringify({ asset_root: assetRoot }));
|
||||||
|
return { assetRoot, configFile, dataRoot, trustedManifestPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("POSTV1-06 portable runtime assets", () => {
|
||||||
|
it("activates a validated external asset root without exposing its path", () => {
|
||||||
|
const input = fixture();
|
||||||
|
const loaded = loadConfiguredRuntimeAssets(input);
|
||||||
|
|
||||||
|
expect(loaded.state).toMatchObject({ configured: true, pause_reason: null, status: "active" });
|
||||||
|
expect(loaded.publicAssets?.read("p0a-static-v1", "STK001")?.bytes.toString()).toBe("synthetic sticker bytes");
|
||||||
|
expect(JSON.stringify(loaded.state)).not.toContain(input.assetRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a changed external manifest and leaves unrelated API features available", () => {
|
||||||
|
const input = fixture();
|
||||||
|
writeFileSync(join(input.assetRoot, "manifest.json"), "{}\n");
|
||||||
|
const loaded = loadConfiguredRuntimeAssets(input);
|
||||||
|
|
||||||
|
expect(loaded.publicAssets).toBeUndefined();
|
||||||
|
expect(loaded.state).toMatchObject({ configured: true, pause_reason: "asset_manifest_invalid", status: "unavailable" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,7 +26,7 @@ describe("TDD-WP0-DATA-001-root-validation resource boundary", () => {
|
|||||||
const assetRoot = join(base, "read-only-assets");
|
const assetRoot = join(base, "read-only-assets");
|
||||||
const relativePath = "images/source.png";
|
const relativePath = "images/source.png";
|
||||||
const bytes = Buffer.from("synthetic png fixture");
|
const bytes = Buffer.from("synthetic png fixture");
|
||||||
const assetId = randomUUID();
|
const assetId = "STK001";
|
||||||
mkdirSync(join(assetRoot, "images"), { recursive: true });
|
mkdirSync(join(assetRoot, "images"), { recursive: true });
|
||||||
writeFileSync(join(assetRoot, relativePath), bytes);
|
writeFileSync(join(assetRoot, relativePath), bytes);
|
||||||
const manifest = JSON.stringify({ assets: [{ asset_id: assetId, relative_path: relativePath }] });
|
const manifest = JSON.stringify({ assets: [{ asset_id: assetId, relative_path: relativePath }] });
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ function routeSession(page: Page) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generatedImageSvg(label: string) {
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"><rect width="300" height="400" fill="#d9f24f"/><text x="150" y="210" text-anchor="middle">${label}</text></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
async function captureEvidence(page: Page, caseId: string, name: string) {
|
async function captureEvidence(page: Page, caseId: string, name: string) {
|
||||||
const root = process.env.DADA_EVIDENCE_DIR_PROJECTS;
|
const root = process.env.DADA_EVIDENCE_DIR_PROJECTS;
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
@@ -75,6 +79,12 @@ test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async
|
|||||||
],
|
],
|
||||||
}), contentType: "application/json", status: 200,
|
}), contentType: "application/json", status: 200,
|
||||||
}));
|
}));
|
||||||
|
await page.route(`**/api/v1/private-assets/projects/${successId}/images/*`, (route) => route.fulfill({
|
||||||
|
body: generatedImageSvg("城市工作室"),
|
||||||
|
contentType: "image/svg+xml",
|
||||||
|
headers: { "Content-Disposition": "attachment; filename=\"dada-original.png\"" },
|
||||||
|
status: 200,
|
||||||
|
}));
|
||||||
let batchPayload: unknown;
|
let batchPayload: unknown;
|
||||||
await page.route("**/api/v1/projects/failed-empty/trash", async (route) => {
|
await page.route("**/api/v1/projects/failed-empty/trash", async (route) => {
|
||||||
batchPayload = route.request().postDataJSON();
|
batchPayload = route.request().postDataJSON();
|
||||||
@@ -86,12 +96,30 @@ test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async
|
|||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "项目" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "项目" })).toBeVisible();
|
||||||
await expect(page.getByText("2 / 20 active")).toBeVisible();
|
await expect(page.getByText("2 / 20 active")).toBeVisible();
|
||||||
|
const projectPreview = page.getByRole("img", { name: "城市工作室预览图" });
|
||||||
|
await expect(projectPreview).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
`/api/v1/private-assets/projects/${successId}/images/00000000-0000-4000-8000-000000000213`,
|
||||||
|
);
|
||||||
|
await expect(projectPreview).toHaveCSS("object-fit", "cover");
|
||||||
await expect(page.getByLabel("选择失败草稿:失败草稿")).toBeVisible();
|
await expect(page.getByLabel("选择失败草稿:失败草稿")).toBeVisible();
|
||||||
await expect(page.getByLabel("选择失败草稿:城市工作室")).toHaveCount(0);
|
await expect(page.getByLabel("选择失败草稿:城市工作室")).toHaveCount(0);
|
||||||
await page.getByLabel("选择失败草稿:失败草稿").check();
|
await page.getByLabel("选择失败草稿:失败草稿").check();
|
||||||
await page.getByRole("button", { name: "批量移入回收站" }).click();
|
const batchTrashButton = page.getByRole("button", { name: "批量移入回收站" });
|
||||||
|
await batchTrashButton.hover();
|
||||||
|
await expect(batchTrashButton).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, -1)");
|
||||||
|
const buttonBounds = await batchTrashButton.boundingBox();
|
||||||
|
if (!buttonBounds) throw new Error("Batch trash button geometry is unavailable.");
|
||||||
|
await page.mouse.move(buttonBounds.x + buttonBounds.width / 2, buttonBounds.y + buttonBounds.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await expect(batchTrashButton).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 1)");
|
||||||
|
await page.mouse.move(0, 0);
|
||||||
|
await page.mouse.up();
|
||||||
|
await batchTrashButton.click();
|
||||||
expect(batchPayload).toEqual({ project_ids: [failedId] });
|
expect(batchPayload).toEqual({ project_ids: [failedId] });
|
||||||
await expect(page.getByText("失败草稿已移入回收站")).toBeVisible();
|
await expect(page.getByText("失败草稿已移入回收站")).toBeVisible();
|
||||||
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||||
|
await expect(page.getByRole("link", { name: "打开项目:城市工作室" })).toHaveCSS("transition-duration", "0s");
|
||||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(375);
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(375);
|
||||||
await captureEvidence(page, "TDD-WP2-PROJ-005-failed-draft-retry", "projects-mobile.png");
|
await captureEvidence(page, "TDD-WP2-PROJ-005-failed-draft-retry", "projects-mobile.png");
|
||||||
});
|
});
|
||||||
@@ -99,9 +127,10 @@ test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async
|
|||||||
test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project detail", async ({ page }) => {
|
test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project detail", async ({ page }) => {
|
||||||
await routeSession(page);
|
await routeSession(page);
|
||||||
const projectId = "00000000-0000-4000-8000-000000000221";
|
const projectId = "00000000-0000-4000-8000-000000000221";
|
||||||
|
const currentImageId = "00000000-0000-4000-8000-000000000222";
|
||||||
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
created_at: "2026-07-28T08:00:00.000Z", current_image_id: "00000000-0000-4000-8000-000000000222",
|
created_at: "2026-07-28T08:00:00.000Z", current_image_id: currentImageId,
|
||||||
draft_prompt: "城市工作室", generations: [], images: Array.from({ length: 10 }, (_, index) => ({
|
draft_prompt: "城市工作室", generations: [], images: Array.from({ length: 10 }, (_, index) => ({
|
||||||
created_at: `2026-07-28T08:${String(index).padStart(2, "0")}:00.000Z`,
|
created_at: `2026-07-28T08:${String(index).padStart(2, "0")}:00.000Z`,
|
||||||
generation_id: `00000000-0000-4000-8000-${String(223 + index).padStart(12, "0")}`,
|
generation_id: `00000000-0000-4000-8000-${String(223 + index).padStart(12, "0")}`,
|
||||||
@@ -111,11 +140,25 @@ test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project
|
|||||||
status: "active", successful_image_count: 10, updated_at: "2026-07-28T08:10:00.000Z",
|
status: "active", successful_image_count: 10, updated_at: "2026-07-28T08:10:00.000Z",
|
||||||
}), contentType: "application/json", status: 200,
|
}), contentType: "application/json", status: 200,
|
||||||
}));
|
}));
|
||||||
|
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/*`, (route) => route.fulfill({
|
||||||
|
body: generatedImageSvg("生成结果"),
|
||||||
|
contentType: "image/svg+xml",
|
||||||
|
headers: { "Content-Disposition": "attachment; filename=\"dada-original.png\"" },
|
||||||
|
status: 200,
|
||||||
|
}));
|
||||||
await page.goto(`${webUrl}/app/projects/${projectId}`);
|
await page.goto(`${webUrl}/app/projects/${projectId}`);
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "城市工作室" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "城市工作室" })).toBeVisible();
|
||||||
await expect(page.getByText("固定比例 3:4")).toBeVisible();
|
await expect(page.getByText("固定比例 3:4")).toBeVisible();
|
||||||
await expect(page.getByText("10 / 10 张成功图")).toBeVisible();
|
await expect(page.getByText("10 / 10 张成功图")).toBeVisible();
|
||||||
|
const currentImage = page.getByRole("img", { name: "城市工作室当前底图" });
|
||||||
|
await expect(currentImage).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
`/api/v1/private-assets/projects/${projectId}/images/${currentImageId}`,
|
||||||
|
);
|
||||||
|
await expect(currentImage).toHaveAttribute("loading", "eager");
|
||||||
|
await expect(currentImage).toHaveCSS("object-fit", "contain");
|
||||||
|
await expect(page.getByRole("img", { name: "生成结果 10" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "继续生成" })).toBeDisabled();
|
await expect(page.getByRole("button", { name: "继续生成" })).toBeDisabled();
|
||||||
await expect(page.getByText("请先删除一张非当前底图的历史图")).toBeVisible();
|
await expect(page.getByText("请先删除一张非当前底图的历史图")).toBeVisible();
|
||||||
await expect(page.getByRole("radio")).toHaveCount(0);
|
await expect(page.getByRole("radio")).toHaveCount(0);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { homedir } from "node:os";
|
|||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import type { CanvasState } from "@dada/shared-contracts";
|
import type { CanvasState } from "@dada/shared-contracts";
|
||||||
|
|
||||||
|
import { P0A_TEXT_TEMPLATES, createTextTemplateElement } from "../../apps/web/src/text-assets.js";
|
||||||
|
|
||||||
let vite: ViteDevServer;
|
let vite: ViteDevServer;
|
||||||
let webUrl: string;
|
let webUrl: string;
|
||||||
|
|
||||||
@@ -196,3 +198,89 @@ test("TDD-WP4-STK-001 transforms, cycles, selects and reopens ordinary stickers"
|
|||||||
writeEvidence("TDD-WP4-STK-001-transform-sticker", "pixel-diff.json", { canvas_and_saved_state_match: true, export_source_canvas_state_stable: true });
|
writeEvidence("TDD-WP4-STK-001-transform-sticker", "pixel-diff.json", { canvas_and_saved_state_match: true, export_source_canvas_state_stable: true });
|
||||||
if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-STK-001-transform-sticker", "transformed-sticker.png") });
|
if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-STK-001-transform-sticker", "transformed-sticker.png") });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("POSTV1-08 keeps the canvas frame stable and previews drag before pointer release", async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const counters = { height: 0, width: 0 };
|
||||||
|
Object.defineProperty(window, "__dadaCanvasDimensionWrites", { value: counters });
|
||||||
|
for (const key of ["height", "width"] as const) {
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, key);
|
||||||
|
if (!descriptor?.get || !descriptor.set) throw new Error(`Canvas ${key} descriptor unavailable.`);
|
||||||
|
Object.defineProperty(HTMLCanvasElement.prototype, key, {
|
||||||
|
configurable: descriptor.configurable,
|
||||||
|
enumerable: descriptor.enumerable,
|
||||||
|
get: descriptor.get,
|
||||||
|
set(value: number) {
|
||||||
|
if (this.classList.contains("editor-canvas")) counters[key] += 1;
|
||||||
|
descriptor.set!.call(this, value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectId = uuid(530);
|
||||||
|
const text = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, {
|
||||||
|
createdAt: "2026-08-03T08:00:00.000Z",
|
||||||
|
elementId: uuid(630),
|
||||||
|
}, 0, { position: { x: 0.5, y: 0.5 } });
|
||||||
|
const backend = { canvas: canvas([text]), saves: 0, version: 6 };
|
||||||
|
await routeEditor(page, projectId, backend);
|
||||||
|
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||||
|
const stage = page.getByLabel("编辑画布");
|
||||||
|
const bounds = await stage.boundingBox();
|
||||||
|
if (!bounds) throw new Error("Canvas bounds unavailable.");
|
||||||
|
const center = { x: bounds.x + bounds.width * 0.5, y: bounds.y + bounds.height * 0.5 };
|
||||||
|
await page.mouse.click(center.x, center.y);
|
||||||
|
await expect(page.getByLabel("文字内容")).toBeVisible();
|
||||||
|
await page.getByLabel("文字内容").fill("拖动中的文字");
|
||||||
|
await page.getByRole("spinbutton", { name: "有效字号", exact: true }).fill("64");
|
||||||
|
await page.getByLabel("文字填充色").fill("#FA5751");
|
||||||
|
await expect.poll(() => backend.saves, { timeout: 4_000 }).toBe(1);
|
||||||
|
expect(backend.canvas.elements[0]).toMatchObject({
|
||||||
|
content: "拖动中的文字",
|
||||||
|
scale: { x: 64 / 48, y: 64 / 48 },
|
||||||
|
style_parameters: { fill_color: "#FA5751" },
|
||||||
|
});
|
||||||
|
const savesBeforeDrag = backend.saves;
|
||||||
|
|
||||||
|
const before = await page.evaluate(() => {
|
||||||
|
return structuredClone((window as typeof window & { __dadaCanvasDimensionWrites: { height: number; width: number } }).__dadaCanvasDimensionWrites);
|
||||||
|
});
|
||||||
|
await page.mouse.move(center.x, center.y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.waitForTimeout(650);
|
||||||
|
await expect(page.getByRole("menu")).toBeVisible();
|
||||||
|
await page.mouse.move(bounds.x + bounds.width * 0.68, center.y);
|
||||||
|
await expect(page.getByRole("menu")).toBeHidden();
|
||||||
|
await expect(page.getByLabel("文字内容")).toBeVisible();
|
||||||
|
|
||||||
|
const preview = await stage.evaluate((canvas: HTMLCanvasElement) => {
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) throw new Error("Canvas context unavailable.");
|
||||||
|
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||||
|
let count = 0;
|
||||||
|
let totalX = 0;
|
||||||
|
for (let y = 0; y < canvas.height; y += 1) {
|
||||||
|
for (let x = 0; x < canvas.width; x += 1) {
|
||||||
|
const offset = (y * canvas.width + x) * 4;
|
||||||
|
if ((pixels[offset] ?? 255) < 20 && (pixels[offset + 1] ?? 0) >= 75 && (pixels[offset + 1] ?? 255) <= 120 && (pixels[offset + 2] ?? 0) >= 180) {
|
||||||
|
count += 1;
|
||||||
|
totalX += x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { blue_pixel_count: count, blue_x: count > 0 ? totalX / count / canvas.width : 0 };
|
||||||
|
});
|
||||||
|
const during = await page.evaluate(() => {
|
||||||
|
return structuredClone((window as typeof window & { __dadaCanvasDimensionWrites: { height: number; width: number } }).__dadaCanvasDimensionWrites);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(during).toEqual(before);
|
||||||
|
expect(preview.blue_pixel_count).toBeGreaterThan(100);
|
||||||
|
expect(preview.blue_x).toBeGreaterThan(0.60);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect.poll(() => backend.saves, { timeout: 4_000 }).toBeGreaterThan(savesBeforeDrag);
|
||||||
|
expect(backend.canvas.elements[0]?.position.x).toBeCloseTo(0.68, 2);
|
||||||
|
expect(backend.canvas.elements[0]?.content).toBe("拖动中的文字");
|
||||||
|
await expect(page.getByLabel("文字内容")).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -98,7 +98,11 @@ test("TDD-WP4-TXT-003 exposes the frozen catalog, display-name search and accoun
|
|||||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(0);
|
await expect(page.locator(".editor-template-grid button")).toHaveCount(0);
|
||||||
await search.fill("");
|
await search.fill("");
|
||||||
expect(page.getByText("添加普通文字", { exact: true })).toHaveCount(0);
|
expect(page.getByText("添加普通文字", { exact: true })).toHaveCount(0);
|
||||||
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
const flowerTemplate = page.getByRole("button", { name: /FLOWER001 春日计划/ });
|
||||||
|
await flowerTemplate.hover();
|
||||||
|
await expect(flowerTemplate).toHaveCSS("background-color", "rgb(255, 255, 214)");
|
||||||
|
await expect(flowerTemplate.locator(".editor-template-mark")).toHaveCSS("transform", "matrix(1.03, 0, 0, 1.03, 0, 0)");
|
||||||
|
await flowerTemplate.click();
|
||||||
await expect(page.getByText("对象 1 / 50")).toBeVisible();
|
await expect(page.getByText("对象 1 / 50")).toBeVisible();
|
||||||
await expect.poll(() => backend.recent).toEqual([{ asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "p0a-complex-v1" }]);
|
await expect.poll(() => backend.recent).toEqual([{ asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "p0a-complex-v1" }]);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -212,3 +216,81 @@ test("TDD-WP4-TXT-002 waits for the archived font and commits exact style ranges
|
|||||||
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "pixel-diff.json", { background_alpha_separate: true, clipped_visible_text: false, effective_font_size: 96 });
|
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "pixel-diff.json", { background_alpha_separate: true, clipped_visible_text: false, effective_font_size: 96 });
|
||||||
if (process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR, "TDD-WP4-TXT-002-font-metrics-ranges", "font-styles.png") });
|
if (process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR, "TDD-WP4-TXT-002-font-metrics-ranges", "font-styles.png") });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("POSTV1-07 keeps the canvas anchored when the text template panel opens", async ({ page }) => {
|
||||||
|
const projectId = uuid(760);
|
||||||
|
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 5 };
|
||||||
|
await routeEditor(page, projectId, backend);
|
||||||
|
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||||
|
|
||||||
|
const stage = page.getByLabel("编辑画布");
|
||||||
|
const before = await stage.boundingBox();
|
||||||
|
if (!before) throw new Error("Canvas geometry is unavailable before opening text templates.");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||||
|
await expect(page.locator(".editor-template-grid button")).toHaveCount(32);
|
||||||
|
const after = await stage.boundingBox();
|
||||||
|
if (!after) throw new Error("Canvas geometry is unavailable after opening text templates.");
|
||||||
|
const assetsPanelScroll = await page.getByLabel("素材与底图来源").evaluate((panel) => ({
|
||||||
|
clientHeight: panel.clientHeight,
|
||||||
|
overflowY: getComputedStyle(panel).overflowY,
|
||||||
|
scrollHeight: panel.scrollHeight,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(Math.abs(after.x - before.x)).toBeLessThanOrEqual(1);
|
||||||
|
expect(Math.abs(after.y - before.y)).toBeLessThanOrEqual(1);
|
||||||
|
expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1);
|
||||||
|
expect(Math.abs(after.height - before.height)).toBeLessThanOrEqual(1);
|
||||||
|
expect(assetsPanelScroll.overflowY).toBe("auto");
|
||||||
|
expect(assetsPanelScroll.scrollHeight).toBeGreaterThan(assetsPanelScroll.clientHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POSTV1-07 keeps text selection stable and dismisses move feedback", async ({ page }) => {
|
||||||
|
const projectId = uuid(770);
|
||||||
|
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 6 };
|
||||||
|
await routeEditor(page, projectId, backend);
|
||||||
|
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||||
|
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: /H003 生活分享家/ }).click();
|
||||||
|
await expect.poll(() => backend.canvas.elements.length).toBe(1);
|
||||||
|
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
const stage = page.getByLabel("编辑画布");
|
||||||
|
const bounds = await stage.boundingBox();
|
||||||
|
const element = backend.canvas.elements[0];
|
||||||
|
if (!bounds || !element) throw new Error("Text selection geometry is unavailable.");
|
||||||
|
const positionBefore = structuredClone(element.position);
|
||||||
|
const savesBefore = backend.saves;
|
||||||
|
const clientX = bounds.x + bounds.width * element.position.x;
|
||||||
|
const clientY = bounds.y + bounds.height * element.position.y;
|
||||||
|
|
||||||
|
await page.mouse.move(clientX, clientY);
|
||||||
|
await page.mouse.down();
|
||||||
|
await expect(page.getByLabel("字体覆盖")).toBeVisible();
|
||||||
|
const selectedBounds = await stage.boundingBox();
|
||||||
|
const inspectorScroll = await page.getByLabel("对象参数").evaluate((panel) => ({
|
||||||
|
clientHeight: panel.clientHeight,
|
||||||
|
overflowY: getComputedStyle(panel).overflowY,
|
||||||
|
scrollHeight: panel.scrollHeight,
|
||||||
|
}));
|
||||||
|
if (!selectedBounds) throw new Error("Canvas geometry is unavailable after selecting text.");
|
||||||
|
expect(Math.abs(selectedBounds.y - bounds.y)).toBeLessThanOrEqual(1);
|
||||||
|
expect(inspectorScroll.overflowY).toBe("auto");
|
||||||
|
expect(inspectorScroll.scrollHeight).toBeGreaterThan(inspectorScroll.clientHeight);
|
||||||
|
await page.mouse.move(clientX + 1, clientY);
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await expect(page.getByLabel("字体覆盖")).toBeVisible();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
expect(backend.canvas.elements[0]?.position).toEqual(positionBefore);
|
||||||
|
expect(backend.saves).toBe(savesBefore);
|
||||||
|
|
||||||
|
await page.mouse.move(clientX, clientY);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(clientX + 12, clientY);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.getByText("对象位置已提交", { exact: true })).toBeVisible();
|
||||||
|
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(savesBefore);
|
||||||
|
await expect(page.getByText("对象位置已提交", { exact: true })).toBeHidden({ timeout: 5_000 });
|
||||||
|
});
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ async function routeEditor(page: Page, projectId: string, backend: Backend, opti
|
|||||||
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => route.fulfill({ body: readFileSync(windowsFont), contentType: "font/ttf" }));
|
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => route.fulfill({ body: readFileSync(windowsFont), contentType: "font/ttf" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
test("TDD-WP4-EXP-001 cancel keeps a pending text edit outside history and export", async ({ page }) => {
|
test("TDD-WP4-EXP-001 cancel keeps automatically saved text outside the export", async ({ page }) => {
|
||||||
const projectId = "00000000-0000-4000-8000-000000000920";
|
const projectId = "00000000-0000-4000-8000-000000000920";
|
||||||
const assetId = "00000000-0000-4000-8000-000000000921";
|
const assetId = "00000000-0000-4000-8000-000000000921";
|
||||||
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 2 };
|
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 2 };
|
||||||
@@ -87,27 +87,29 @@ test("TDD-WP4-EXP-001 cancel keeps a pending text edit outside history and expor
|
|||||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||||
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
||||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
||||||
await page.getByLabel("文字内容").fill("尚未提交的导出文字");
|
await page.getByLabel("文字内容").fill("自动保存的导出文字");
|
||||||
|
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(2);
|
||||||
const downloads: string[] = [];
|
const downloads: string[] = [];
|
||||||
page.on("download", (download) => downloads.push(download.suggestedFilename()));
|
page.on("download", (download) => downloads.push(download.suggestedFilename()));
|
||||||
await page.getByRole("button", { name: "导出", exact: true }).click();
|
await page.getByRole("button", { name: "导出", exact: true }).click();
|
||||||
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
||||||
await expect(dialog).toContainText("导出前需要提交当前修改");
|
await expect(dialog).not.toContainText("导出前需要提交当前修改");
|
||||||
await expect(dialog.getByText("将应用当前修改并导出", { exact: true })).toBeVisible();
|
await expect(dialog.getByRole("checkbox", { name: "将应用当前修改并导出" })).toHaveCount(0);
|
||||||
await dialog.getByRole("button", { name: "取消" }).click();
|
await dialog.getByRole("button", { name: "取消" }).click();
|
||||||
await expect(dialog).toHaveCount(0);
|
await expect(dialog).toHaveCount(0);
|
||||||
await expect(page.getByLabel("文字内容")).toHaveValue("尚未提交的导出文字");
|
await expect(page.getByLabel("文字内容")).toHaveValue("自动保存的导出文字");
|
||||||
expect(backend.saves).toBe(1);
|
expect(backend.saves).toBe(2);
|
||||||
|
expect(backend.canvas.elements[0]?.content).toBe("自动保存的导出文字");
|
||||||
expect(backend.latestBodies).toHaveLength(0);
|
expect(backend.latestBodies).toHaveLength(0);
|
||||||
expect(downloads).toHaveLength(0);
|
expect(downloads).toHaveLength(0);
|
||||||
const beforeUndo = { download_count: 0, latest_count: 0, save_count_after_cancel: backend.saves, state_version: backend.version };
|
const beforeUndo = { download_count: 0, latest_count: 0, save_count_after_cancel: backend.saves, state_version: backend.version };
|
||||||
await page.getByRole("button", { name: "撤销" }).click();
|
await page.getByRole("button", { name: "撤销" }).click();
|
||||||
await expect(page.getByLabel("文字内容")).toHaveCount(0);
|
await expect(page.getByLabel("文字内容")).toHaveValue("春日计划");
|
||||||
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "network-timeline.json", { ...beforeUndo, compose_calls: 0, export_save_calls: 0 });
|
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "network-timeline.json", { ...beforeUndo, compose_calls: 0, export_save_calls: 0 });
|
||||||
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "db-diff.json", { committed_text_after_cancel: "春日计划", first_undo_removed_initial_element: true, latest_exports_changed: false });
|
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "db-diff.json", { committed_text_after_cancel: "自动保存的导出文字", first_undo_restored_initial_text: true, latest_exports_changed: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("TDD-WP4-EXP-001 confirm commits once, downloads, and saves the same bytes", async ({ page }) => {
|
test("TDD-WP4-EXP-001 exports automatically saved text and saves the same bytes", async ({ page }) => {
|
||||||
const projectId = "00000000-0000-4000-8000-000000000930";
|
const projectId = "00000000-0000-4000-8000-000000000930";
|
||||||
const assetId = "00000000-0000-4000-8000-000000000931";
|
const assetId = "00000000-0000-4000-8000-000000000931";
|
||||||
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 4 };
|
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 4 };
|
||||||
@@ -117,6 +119,7 @@ test("TDD-WP4-EXP-001 confirm commits once, downloads, and saves the same bytes"
|
|||||||
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
||||||
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
|
||||||
await page.getByLabel("文字内容").fill("确认后进入导出");
|
await page.getByLabel("文字内容").fill("确认后进入导出");
|
||||||
|
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(2);
|
||||||
await page.getByRole("button", { name: "导出", exact: true }).click();
|
await page.getByRole("button", { name: "导出", exact: true }).click();
|
||||||
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
const dialog = page.getByRole("dialog", { name: "导出成品" });
|
||||||
if (process.env.DADA_EVIDENCE_DIR_EXPORT) {
|
if (process.env.DADA_EVIDENCE_DIR_EXPORT) {
|
||||||
@@ -124,14 +127,14 @@ test("TDD-WP4-EXP-001 confirm commits once, downloads, and saves the same bytes"
|
|||||||
mkdirSync(dirname(screenshot), { recursive: true });
|
mkdirSync(dirname(screenshot), { recursive: true });
|
||||||
await page.screenshot({ fullPage: true, path: screenshot });
|
await page.screenshot({ fullPage: true, path: screenshot });
|
||||||
}
|
}
|
||||||
await dialog.getByRole("checkbox", { name: "将应用当前修改并导出" }).check();
|
await expect(dialog.getByRole("checkbox", { name: "将应用当前修改并导出" })).toHaveCount(0);
|
||||||
const downloadPromise = page.waitForEvent("download");
|
const downloadPromise = page.waitForEvent("download");
|
||||||
await dialog.getByRole("button", { name: "导出并下载" }).click();
|
await dialog.getByRole("button", { name: "导出并下载" }).click();
|
||||||
const download = await downloadPromise;
|
const download = await downloadPromise;
|
||||||
const downloadPath = await download.path();
|
const downloadPath = await download.path();
|
||||||
if (!downloadPath) throw new Error("Browser download did not expose a local path.");
|
if (!downloadPath) throw new Error("Browser download did not expose a local path.");
|
||||||
await expect(dialog.getByRole("status")).toHaveText("已下载并保存为最新成品");
|
await expect(dialog.getByRole("status")).toHaveText("已下载并保存为最新成品");
|
||||||
await expect.poll(() => backend.saves).toBe(2);
|
expect(backend.saves).toBe(2);
|
||||||
expect(backend.canvas.elements[0]?.content).toBe("确认后进入导出");
|
expect(backend.canvas.elements[0]?.content).toBe("确认后进入导出");
|
||||||
expect(backend.latestBodies).toHaveLength(1);
|
expect(backend.latestBodies).toHaveLength(1);
|
||||||
const downloaded = readFileSync(downloadPath);
|
const downloaded = readFileSync(downloadPath);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
defaultLocalDataRoot,
|
defaultLocalDataRoot,
|
||||||
initializeLocalDataRoot,
|
initializeLocalDataRoot,
|
||||||
inspectInitializedLocalDataRoot,
|
inspectInitializedLocalDataRoot,
|
||||||
|
readConfiguredAssetRoot,
|
||||||
resolvePathWithinRoot,
|
resolvePathWithinRoot,
|
||||||
validateLocalDataRoot,
|
validateLocalDataRoot,
|
||||||
validateReadOnlyAssetRoot,
|
validateReadOnlyAssetRoot,
|
||||||
@@ -66,6 +67,17 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("TDD-WP0-DATA-001-root-validation", () => {
|
describe("TDD-WP0-DATA-001-root-validation", () => {
|
||||||
|
it("reads only an absolute configured read-only asset root", () => {
|
||||||
|
const base = temporaryDirectory();
|
||||||
|
const configFile = join(base, "instance.json");
|
||||||
|
const assetRoot = join(base, "runtime-assets");
|
||||||
|
writeFileSync(configFile, JSON.stringify({ asset_root: assetRoot }));
|
||||||
|
|
||||||
|
expect(readConfiguredAssetRoot(configFile)).toBe(resolve(assetRoot));
|
||||||
|
writeFileSync(configFile, JSON.stringify({ asset_root: "relative-assets" }));
|
||||||
|
expect(() => readConfiguredAssetRoot(configFile)).toThrow("asset_root_configuration_invalid");
|
||||||
|
});
|
||||||
|
|
||||||
it("derives default data and configuration paths from LOCALAPPDATA without a hardcoded user", () => {
|
it("derives default data and configuration paths from LOCALAPPDATA without a hardcoded user", () => {
|
||||||
const localAppData = join(temporaryDirectory(), "LocalAppData");
|
const localAppData = join(temporaryDirectory(), "LocalAppData");
|
||||||
expect(defaultLocalDataRoot({ LOCALAPPDATA: localAppData })).toBe(join(localAppData, "Dada", "P0A", "data"));
|
expect(defaultLocalDataRoot({ LOCALAPPDATA: localAppData })).toBe(join(localAppData, "Dada", "P0A", "data"));
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
test("POSTV1-11 release accepts the Codex embedded Chrome major", () => {
|
||||||
|
const release = JSON.parse(
|
||||||
|
fs.readFileSync(path.resolve("RELEASE.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const chrome = release.browsers.find(({ brand }) => brand === "Google Chrome");
|
||||||
|
|
||||||
|
assert.ok(chrome, "release must include Google Chrome");
|
||||||
|
assert.equal(Number.parseInt(chrome.fullVersion.split(".")[0], 10), 151);
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createRuntimeAssetManifest,
|
||||||
|
deployRuntimeAssetPlan,
|
||||||
|
readRuntimeAssetManifest,
|
||||||
|
serializeRuntimeAssetManifest,
|
||||||
|
} from "../../scripts/lib/runtime-assets.mjs";
|
||||||
|
|
||||||
|
test("committed P0-A runtime manifest covers the frozen first-version binary assets", () => {
|
||||||
|
const manifest = readRuntimeAssetManifest("config/runtime-assets-manifest.json");
|
||||||
|
assert.deepEqual(manifest.counts, {
|
||||||
|
dynamic_fonts: 7,
|
||||||
|
dynamic_images: 8,
|
||||||
|
font_panel_items: 11,
|
||||||
|
static_stickers: 1407,
|
||||||
|
});
|
||||||
|
assert.equal(manifest.entries.length, 1433);
|
||||||
|
assert.doesNotMatch(serializeRuntimeAssetManifest(manifest), /[A-Za-z]:[\\/]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runtime asset deployment creates verified hardlinks and a path-free manifest", async (t) => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "dada-runtime-assets-"));
|
||||||
|
t.after(() => rm(root, { force: true, recursive: true }));
|
||||||
|
const sourceRoot = join(root, "source");
|
||||||
|
const assetRoot = join(root, "assets");
|
||||||
|
const sourcePath = join(sourceRoot, "sticker.png");
|
||||||
|
const bytes = Buffer.from("runtime asset fixture");
|
||||||
|
await mkdir(sourceRoot);
|
||||||
|
await writeFile(sourcePath, bytes);
|
||||||
|
const entry = {
|
||||||
|
assetId: "STK001",
|
||||||
|
mimeType: "image/png",
|
||||||
|
relativePath: "p0a-static-v1/STK001.png",
|
||||||
|
resourceVersion: "p0a-static-v1",
|
||||||
|
rootRef: "p0a_runtime_assets",
|
||||||
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||||
|
};
|
||||||
|
const manifest = createRuntimeAssetManifest({
|
||||||
|
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||||
|
entries: [entry],
|
||||||
|
});
|
||||||
|
|
||||||
|
await deployRuntimeAssetPlan({ assetRoot, manifest, resources: [{ entry, sourcePath }] });
|
||||||
|
|
||||||
|
const targetPath = join(assetRoot, entry.relativePath);
|
||||||
|
const [sourceStat, targetStat] = await Promise.all([stat(sourcePath), stat(targetPath)]);
|
||||||
|
assert.equal(sourceStat.ino, targetStat.ino);
|
||||||
|
assert.deepEqual(await readFile(targetPath), bytes);
|
||||||
|
const writtenManifest = await readFile(join(assetRoot, "manifest.json"), "utf8");
|
||||||
|
assert.deepEqual(JSON.parse(writtenManifest), manifest);
|
||||||
|
assert.doesNotMatch(writtenManifest, /[A-Za-z]:[\\/]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runtime asset deployment refuses a mismatched existing target", async (t) => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "dada-runtime-assets-conflict-"));
|
||||||
|
t.after(() => rm(root, { force: true, recursive: true }));
|
||||||
|
const sourcePath = join(root, "source.png");
|
||||||
|
const assetRoot = join(root, "assets");
|
||||||
|
const targetPath = join(assetRoot, "p0a-static-v1", "STK001.png");
|
||||||
|
await mkdir(join(assetRoot, "p0a-static-v1"), { recursive: true });
|
||||||
|
await writeFile(sourcePath, "expected");
|
||||||
|
await writeFile(targetPath, "unexpected");
|
||||||
|
const entry = {
|
||||||
|
assetId: "STK001",
|
||||||
|
mimeType: "image/png",
|
||||||
|
relativePath: "p0a-static-v1/STK001.png",
|
||||||
|
resourceVersion: "p0a-static-v1",
|
||||||
|
rootRef: "p0a_runtime_assets",
|
||||||
|
sha256: createHash("sha256").update("expected").digest("hex"),
|
||||||
|
};
|
||||||
|
const manifest = createRuntimeAssetManifest({
|
||||||
|
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||||
|
entries: [entry],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => deployRuntimeAssetPlan({ assetRoot, manifest, resources: [{ entry, sourcePath }] }),
|
||||||
|
/asset_target_conflict/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -112,6 +112,14 @@ async function verifyWorkerStartup(configPath) {
|
|||||||
test("portable package serves the product and keeps SQLite data across API restart", async () => {
|
test("portable package serves the product and keeps SQLite data across API restart", async () => {
|
||||||
assert.ok(existsSync(join(packageRoot, "Dada.exe")));
|
assert.ok(existsSync(join(packageRoot, "Dada.exe")));
|
||||||
assert.ok(existsSync(join(packageRoot, "web", "index.html")));
|
assert.ok(existsSync(join(packageRoot, "web", "index.html")));
|
||||||
|
const runtimeAssetManifest = JSON.parse(await readFile(join(packageRoot, "asset-metadata", "manifest.json"), "utf8"));
|
||||||
|
assert.deepEqual(runtimeAssetManifest.counts, {
|
||||||
|
dynamic_fonts: 7,
|
||||||
|
dynamic_images: 8,
|
||||||
|
font_panel_items: 11,
|
||||||
|
static_stickers: 1407,
|
||||||
|
});
|
||||||
|
assert.equal(runtimeAssetManifest.entries.length, 1433);
|
||||||
const packagedWorker = await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8");
|
const packagedWorker = await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8");
|
||||||
const packagedOneApiAdapter = await readFile(join(packageRoot, "server", "worker", "dist", "oneapi-generation-adapter.js"), "utf8");
|
const packagedOneApiAdapter = await readFile(join(packageRoot, "server", "worker", "dist", "oneapi-generation-adapter.js"), "utf8");
|
||||||
assert.match(packagedWorker, /GenerationProcessor/);
|
assert.match(packagedWorker, /GenerationProcessor/);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { GenerationPollingLoop } from "../../apps/worker/src/generation-polling-loop.js";
|
||||||
|
|
||||||
|
describe("POSTV1-05 generation polling loop", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not start another processor call while the current call is unresolved", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let finishCurrentCall: (() => void) | undefined;
|
||||||
|
const processNext = vi.fn(() => new Promise<void>((resolve) => {
|
||||||
|
finishCurrentCall = resolve;
|
||||||
|
}));
|
||||||
|
const loop = new GenerationPollingLoop({ processNext }, 250);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
expect(processNext).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
finishCurrentCall?.();
|
||||||
|
await Promise.resolve();
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
|
expect(processNext).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
loop.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,6 +26,14 @@ describe("POSTV1-02 OneAPI runtime adapter", () => {
|
|||||||
const headers = new Headers(init?.headers);
|
const headers = new Headers(init?.headers);
|
||||||
expect(headers.get("authorization")).toBe("Bearer synthetic-runtime-token");
|
expect(headers.get("authorization")).toBe("Bearer synthetic-runtime-token");
|
||||||
expect(init?.redirect).toBe("error");
|
expect(init?.redirect).toBe("error");
|
||||||
|
const payload = JSON.parse(String(init?.body)) as { messages: Array<{ content: unknown; role: string }> };
|
||||||
|
expect(payload.messages).toEqual([
|
||||||
|
{
|
||||||
|
content: "Generate exactly one image from the user's description. Return the generated image and do not answer with text only.",
|
||||||
|
role: "system",
|
||||||
|
},
|
||||||
|
{ content: "一张用于本机验收的抽象色彩图", role: "user" },
|
||||||
|
]);
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
choices: [{ message: { content: `})` } }],
|
choices: [{ message: { content: `})` } }],
|
||||||
}), { headers: { "content-type": "application/json" }, status: 200 });
|
}), { headers: { "content-type": "application/json" }, status: 200 });
|
||||||
|
|||||||
Reference in New Issue
Block a user