media.sync, media.proxy, media.analyze, and media.cluster for Media Studio: incremental metadata sync with deletion detection, 720p CFR proxies with 1 s keyframes, tone mapping, posters and sprites, sidecar analyses stored per model version with scored segments, and sessions, themes, and person suggestions. Proxy and analysis work is pumped in a small per-space window, jobs step aside when their GPU or CPU permit is taken, and a full disk or an unreachable sidecar pauses work instead of failing assets. Re-clustering keeps collections the user merged or split. Fixes photo posters, which never rendered because ffmpeg yields no frame when seeking a single image. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
78 lines
3.0 KiB
TypeScript
78 lines
3.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { reconcileUserEdits } from "./media-ingest-cluster.js";
|
|
|
|
const computed = [
|
|
{ key: "session:a1", assetIds: ["a1", "a2", "a3"] },
|
|
{ key: "session:b1", assetIds: ["b1", "b2"] },
|
|
{ key: "session:c1", assetIds: ["c1", "c2", "c3"] },
|
|
];
|
|
|
|
const keys = (clusters: Array<{ key: string; assetIds: string[] }>) =>
|
|
clusters.map((cluster) => [cluster.key, cluster.assetIds]);
|
|
|
|
describe("reconcileUserEdits", () => {
|
|
it("passes computed clusters through when the user edited nothing", () => {
|
|
expect(reconcileUserEdits(computed, [], "session")).toEqual(computed);
|
|
});
|
|
|
|
it("never re-adds the assets of a merged collection to computed clusters", () => {
|
|
// The user merged a1.. and b1.. into the a1 collection.
|
|
const merged = { clusterKey: "session:a1", assetIds: ["a1", "a2", "a3", "b1", "b2"] };
|
|
expect(keys(reconcileUserEdits(computed, [merged], "session"))).toEqual([
|
|
["session:c1", ["c1", "c2", "c3"]],
|
|
]);
|
|
});
|
|
|
|
it("keeps both halves of a split and places new assets outside them", () => {
|
|
// Split c1 | c2 c3; a later sync adds c4 to the computed session.
|
|
const grown = [
|
|
...computed.slice(0, 2),
|
|
{ key: "session:c1", assetIds: ["c1", "c2", "c3", "c4"] },
|
|
];
|
|
const kept = { clusterKey: "session:c1", assetIds: ["c1"] };
|
|
const splitOff = { clusterKey: null, assetIds: ["c2", "c3"] };
|
|
expect(keys(reconcileUserEdits(grown, [kept, splitOff], "session"))).toEqual([
|
|
["session:a1", ["a1", "a2", "a3"]],
|
|
["session:b1", ["b1", "b2"]],
|
|
["session:c1", ["c1", "c4"]],
|
|
]);
|
|
// When the kept half is not a subset any more it freezes; c4 gets its own key.
|
|
const moved = [...computed.slice(0, 2), { key: "session:c0", assetIds: ["c0", "c4"] }];
|
|
expect(
|
|
keys(reconcileUserEdits(moved, [{ ...kept, assetIds: ["c1", "c9"] }], "session")),
|
|
).toEqual([
|
|
["session:a1", ["a1", "a2", "a3"]],
|
|
["session:b1", ["b1", "b2"]],
|
|
["session:c0", ["c0", "c4"]],
|
|
]);
|
|
});
|
|
|
|
it("lets a renamed collection follow its cluster, even when the cluster key changes", () => {
|
|
const renamed = { clusterKey: "session:a1", assetIds: ["a1", "a2"] };
|
|
const earlier = [
|
|
{ key: "session:a0", assetIds: ["a0", "a1", "a2", "a3"] },
|
|
...computed.slice(1),
|
|
];
|
|
expect(keys(reconcileUserEdits(earlier, [renamed], "session"))).toEqual([
|
|
["session:a1", ["a0", "a1", "a2", "a3"]],
|
|
["session:b1", ["b1", "b2"]],
|
|
["session:c1", ["c1", "c2", "c3"]],
|
|
]);
|
|
});
|
|
|
|
it("re-keys a cluster that lost its first asset to a split-off collection", () => {
|
|
const splitOff = { clusterKey: null, assetIds: ["a1"] };
|
|
expect(keys(reconcileUserEdits(computed, [splitOff], "theme"))).toEqual([
|
|
["theme:a2", ["a2", "a3"]],
|
|
["session:b1", ["b1", "b2"]],
|
|
["session:c1", ["c1", "c2", "c3"]],
|
|
]);
|
|
});
|
|
|
|
it("ignores empty user collections", () => {
|
|
expect(
|
|
reconcileUserEdits(computed, [{ clusterKey: "session:a1", assetIds: [] }], "session"),
|
|
).toEqual(computed);
|
|
});
|
|
});
|