fix(panel): conform auto-resolves target project from clip assets

Conform was pushing clips to whatever project the operator picked in
#conform-proj-select. mam-api's PUT /sequences/:id/clips requires every clip
asset to belong to the sequence's project, so picking the "wrong" project (or
a timeline whose proxies were imported under a different project) produced a
silent HTTP 400 ("All clip assets must belong to the sequence's project") that
surfaced only as "clip push HTTP 400".

pushToMAM now looks up each matched asset's real project_id and:
  - if all matched assets share ONE project, conforms into THAT project
    (overriding the picked one) so the common "wrong project picked" case
    just works;
  - if the timeline genuinely mixes projects, throws a clear, actionable
    error naming the clips per project instead of a generic 400.

Pure editor/conform-path change. Does NOT touch capture, recorders, the
deltacast bridge, framecache, or node-agent.
This commit is contained in:
Zac Gaetano 2026-06-05 11:51:55 -04:00
parent 7a08b90ce7
commit 34809e9337

View file

@ -1,4 +1,4 @@
// timeline.js — v2.1.6
// timeline.js — v2.2.0
// premierepro API: docs say sync, runtime returns Promises. Await everything.
(function () {
@ -195,19 +195,66 @@
return out.join('\n');
};
// Resolve the MAM project that actually owns the matched clip assets.
// mam-api requires every clip in PUT /sequences/:id/clips to belong to the
// sequence's project; pushing to the "wrong" project 400s. Rather than trust
// the operator's picked project, ask the server which project each asset
// lives in and reconcile:
// • all assets in one project → return that project (authoritative).
// • assets span >1 project → throw a clear, per-project breakdown.
// requestedProjectId is only used as a tie-break hint / for the error text.
Timeline._resolveClipProject = async function (matched, requestedProjectId) {
const ids = [...new Set(matched.map(c => c.asset_id).filter(Boolean))];
// Map asset_id → project_id (+ display name for error messages).
const byProject = {}; // projectId → [{id, name}]
for (const id of ids) {
let asset;
try { asset = await API.getAsset(id); }
catch (e) { throw new Error('Could not look up asset ' + id + ': ' + e.message); }
const pid = asset && (asset.project_id || asset.projectId);
if (!pid) throw new Error('Asset "' + ((asset && (asset.display_name || asset.filename)) || id) + '" has no project — re-import it before conforming.');
(byProject[pid] = byProject[pid] || []).push({
id, name: (asset.display_name || asset.filename || id),
});
}
const projectIds = Object.keys(byProject);
if (projectIds.length === 0) throw new Error('No resolvable clip assets to conform.');
if (projectIds.length === 1) return projectIds[0];
// Genuinely mixed timeline — build an actionable message.
const lines = projectIds.map(pid => {
const names = byProject[pid].map(a => a.name).slice(0, 4);
const more = byProject[pid].length > 4 ? ' +' + (byProject[pid].length - 4) + ' more' : '';
return '• project ' + pid + ': ' + names.join(', ') + more;
});
throw new Error(
'This timeline mixes clips from ' + projectIds.length + ' different MAM projects, ' +
'so it cannot conform into one sequence:\n' + lines.join('\n') +
'\nKeep each conform to clips from a single project (or re-import the sources into one project).'
);
};
// ── Push Timeline to MAM ─────────────────────────────────────────
Timeline.pushToMAM = async function (seqName, projectId, td) {
const resolved = Library.resolveClipsToAssets(td.clips || []);
const matched = resolved.filter(c => c.asset_id);
if (!matched.length) throw new Error('No clips matched MAM assets — import proxies first so the plugin can map file paths to asset IDs');
const seqs = await API.listSequences(projectId);
// Conform into the project that actually owns the clip assets. This makes
// the common "operator picked the wrong project" case just work, and turns
// a genuinely mixed timeline into a clear error instead of an opaque 400.
const effProjectId = await Timeline._resolveClipProject(matched, projectId);
const seqs = await API.listSequences(effProjectId);
let seqId;
const existing = seqs.find(s => s.name === seqName);
if (existing) {
await API.updateSequence(existing.id, { frame_rate: td.frameRate, width: td.width, height: td.height });
seqId = existing.id;
} else {
const created = await API.createSequence(projectId, seqName, td.frameRate, td.width, td.height);
const created = await API.createSequence(effProjectId, seqName, td.frameRate, td.width, td.height);
seqId = created.id;
}
await API.pushClips(seqId, matched.map(c => ({
@ -215,7 +262,7 @@
timeline_in_frames: c.timelineInFrames, timeline_out_frames: c.timelineOutFrames,
source_in_frames: c.sourceInFrames, source_out_frames: c.sourceOutFrames,
})));
return { seqId, matched: matched.length, skipped: resolved.length - matched.length };
return { seqId, projectId: effProjectId, matched: matched.length, skipped: resolved.length - matched.length };
};
// ── Conform ──────────────────────────────────────────────────────