feat(materials): add campaign materials overlay for sessions
Let GMs manage and show images over the scene from the editor and control panel, with zoom tools, help docs, and full ru/en i18n. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -43,8 +43,8 @@ import type {
|
||||
SceneId,
|
||||
} from '../../shared/types';
|
||||
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
|
||||
import type { AssetId, GraphNodeId } from '../../shared/types/ids';
|
||||
import { asAssetId, asGraphNodeId, asProjectId } from '../../shared/types/ids';
|
||||
import type { AssetId, GraphNodeId, MaterialId } from '../../shared/types/ids';
|
||||
import { asAssetId, asGraphNodeId, asMaterialId, asProjectId } from '../../shared/types/ids';
|
||||
import { getAppSemanticVersion } from '../versionInfo';
|
||||
|
||||
import { reconcileAssetFiles } from './assetPrune';
|
||||
@@ -225,6 +225,7 @@ export class ZipProjectStore {
|
||||
sceneListOrder: [],
|
||||
assets: {},
|
||||
campaignAudios: [],
|
||||
materials: [],
|
||||
currentSceneId: null,
|
||||
currentGraphNodeId: null,
|
||||
sceneGraphNodes: [],
|
||||
@@ -1002,6 +1003,137 @@ export class ZipProjectStore {
|
||||
return latest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт или обновляет материал кампании.
|
||||
* При создании `filePath` обязателен; при обновлении можно сменить только имя или только картинку.
|
||||
*/
|
||||
async upsertMaterial(input: {
|
||||
materialId?: MaterialId;
|
||||
name: string;
|
||||
filePath?: string;
|
||||
}): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
const name = input.name.trim();
|
||||
if (name.length < 1) throw new Error('Material name is required');
|
||||
const nameKey = name.toLowerCase();
|
||||
const existing = open.project.materials ?? [];
|
||||
const editingId = input.materialId ?? null;
|
||||
if (existing.some((m) => m.id !== editingId && m.name.trim().toLowerCase() === nameKey)) {
|
||||
throw new Error('Material name already exists');
|
||||
}
|
||||
|
||||
let nextAssetId: AssetId | null = null;
|
||||
let stagedAsset: MediaAsset | null = null;
|
||||
if (input.filePath) {
|
||||
const kind = classifyMediaPath(input.filePath);
|
||||
if (kind?.type !== 'image') throw new Error('Material must be an image (png/jpg/webp)');
|
||||
const ext = path.extname(input.filePath).toLowerCase();
|
||||
if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
|
||||
throw new Error('Material must be an image (png/jpg/webp)');
|
||||
}
|
||||
let buf = await fs.readFile(input.filePath);
|
||||
try {
|
||||
const opt = await optimizeImageBufferVisuallyLossless(buf);
|
||||
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
|
||||
} catch {
|
||||
// keep original buffer
|
||||
}
|
||||
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
const id = asAssetId(this.randomId());
|
||||
const orig = path.basename(input.filePath);
|
||||
const safeOrig = sanitizeFileName(orig);
|
||||
const relPath = `assets/${id}_${safeOrig}`;
|
||||
const abs = path.join(open.cacheDir, relPath);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await fs.writeFile(abs, buf);
|
||||
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
|
||||
nextAssetId = id;
|
||||
}
|
||||
|
||||
await this.updateProject((p) => {
|
||||
const materials = [...(p.materials ?? [])];
|
||||
const assets = { ...p.assets };
|
||||
if (stagedAsset) assets[stagedAsset.id] = stagedAsset;
|
||||
|
||||
if (editingId) {
|
||||
const idx = materials.findIndex((m) => m.id === editingId);
|
||||
if (idx < 0) throw new Error('Material not found');
|
||||
const prev = materials[idx]!;
|
||||
const assetId = nextAssetId ?? prev.assetId;
|
||||
materials[idx] = {
|
||||
id: editingId,
|
||||
name,
|
||||
assetId,
|
||||
rotationDeg: prev.rotationDeg ?? 0,
|
||||
};
|
||||
} else {
|
||||
if (!nextAssetId) throw new Error('Material image is required');
|
||||
materials.push({
|
||||
id: asMaterialId(`mat_${this.randomId()}`),
|
||||
name,
|
||||
assetId: nextAssetId,
|
||||
rotationDeg: 0,
|
||||
});
|
||||
}
|
||||
return { ...p, assets, materials };
|
||||
});
|
||||
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async setMaterialRotation(
|
||||
materialId: MaterialId,
|
||||
rotationDeg: 0 | 90 | 180 | 270,
|
||||
): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
await this.updateProject((p) => {
|
||||
const materials = (p.materials ?? []).map((m) =>
|
||||
m.id === materialId ? { ...m, rotationDeg } : m,
|
||||
);
|
||||
return { ...p, materials };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async deleteMaterial(materialId: MaterialId): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
await this.updateProject((p) => ({
|
||||
...p,
|
||||
materials: (p.materials ?? []).filter((m) => m.id !== materialId),
|
||||
}));
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async setMaterialsOrder(materialIds: MaterialId[]): Promise<Project> {
|
||||
const open = this.openProject;
|
||||
if (!open) throw new Error('No open project');
|
||||
await this.updateProject((p) => {
|
||||
const byId = new Map((p.materials ?? []).map((m) => [m.id, m]));
|
||||
const next: NonNullable<Project['materials']> = [];
|
||||
for (const id of materialIds) {
|
||||
const m = byId.get(id);
|
||||
if (m) {
|
||||
next.push(m);
|
||||
byId.delete(id);
|
||||
}
|
||||
}
|
||||
for (const m of byId.values()) next.push(m);
|
||||
return { ...p, materials: next };
|
||||
});
|
||||
const latest = this.getOpenProject();
|
||||
if (!latest) throw new Error('No open project');
|
||||
return latest;
|
||||
}
|
||||
|
||||
async saveNow(): Promise<void> {
|
||||
const open = this.openProject;
|
||||
if (!open) return;
|
||||
@@ -1717,6 +1849,22 @@ function normalizeProject(p: Project): Project {
|
||||
return null;
|
||||
})
|
||||
.filter((x): x is { assetId: AssetId; autoplay: boolean; loop: boolean } => Boolean(x));
|
||||
const rawMaterials = (p as unknown as { materials?: unknown[] }).materials;
|
||||
const materials = (Array.isArray(rawMaterials) ? rawMaterials : [])
|
||||
.map((m) => {
|
||||
if (!m || typeof m !== 'object') return null;
|
||||
const obj = m as { id?: string; name?: string; assetId?: AssetId; rotationDeg?: number };
|
||||
if (!obj.id || !obj.assetId || typeof obj.name !== 'string') return null;
|
||||
const name = obj.name.trim();
|
||||
if (!name) return null;
|
||||
const rot = obj.rotationDeg;
|
||||
const rotationDeg: 0 | 90 | 180 | 270 = rot === 90 || rot === 180 || rot === 270 ? rot : 0;
|
||||
return { id: asMaterialId(String(obj.id)), name, assetId: obj.assetId, rotationDeg };
|
||||
})
|
||||
.filter(
|
||||
(x): x is { id: MaterialId; name: string; assetId: AssetId; rotationDeg: 0 | 90 | 180 | 270 } =>
|
||||
Boolean(x),
|
||||
);
|
||||
const metaRaw = p.meta as unknown as { createdWithAppVersion?: string; appVersion?: string };
|
||||
const createdWithAppVersion = (() => {
|
||||
const c = metaRaw.createdWithAppVersion?.trim();
|
||||
@@ -1737,6 +1885,7 @@ function normalizeProject(p: Project): Project {
|
||||
},
|
||||
scenes,
|
||||
campaignAudios,
|
||||
materials,
|
||||
sceneGraphNodes,
|
||||
sceneGraphEdges,
|
||||
currentGraphNodeId,
|
||||
|
||||
Reference in New Issue
Block a user