@nebutra/cinema 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ import { CapabilityError } from '@nebutra/capability-kit';
2
+
3
+ /**
4
+ * Consistency-ranked best-frame selection — ViMax-distinct IP, re-expressed.
5
+ *
6
+ * Generate N candidate frames, then have an MLLM rank them for character /
7
+ * spatial / semantic consistency against the target description and pick the
8
+ * best. The ranking is injected (a model call in production); this module
9
+ * owns only the orchestration + validation invariants.
10
+ */
11
+ interface FrameCandidate {
12
+ readonly id: string;
13
+ readonly uri: string;
14
+ }
15
+ /** Injected ranker: returns the winning candidate id + a human reason. */
16
+ type RankFrames = (candidates: readonly FrameCandidate[], targetDescription: string) => Promise<{
17
+ bestId: string;
18
+ reason: string;
19
+ }>;
20
+ interface BestFrame {
21
+ readonly best: FrameCandidate;
22
+ readonly reason: string;
23
+ }
24
+ /**
25
+ * Select the most consistent frame. Throws if there are no candidates or if
26
+ * the ranker returns an id that is not among them (a model contract breach
27
+ * must never silently pick the wrong frame).
28
+ */
29
+ declare function selectBestFrame(candidates: readonly FrameCandidate[], targetDescription: string, rank: RankFrames): Promise<BestFrame>;
30
+
31
+ /**
32
+ * Camera-continuity tree — the ViMax-distinct IP, re-expressed.
33
+ *
34
+ * A film is shot by multiple cameras; a parent camera's footage encompasses
35
+ * its children's, so a child shot can inherit the parent's frames for
36
+ * temporal/character continuity. The parent inference itself is a model call
37
+ * (injected — never bound to a provider here). The structural invariants are
38
+ * enforced locally with the neutral `@nebutra/graph-model` acyclic guard
39
+ * (governance: reuse, don't re-implement cycle detection).
40
+ */
41
+ interface Camera {
42
+ readonly id: string;
43
+ readonly shotIds: readonly string[];
44
+ }
45
+ interface CameraParent {
46
+ readonly cameraId: string;
47
+ /** Null = this camera is a root. */
48
+ readonly parentCameraId: string | null;
49
+ /** Which parent shot subsumes this camera (null at root). */
50
+ readonly parentShotId: string | null;
51
+ readonly fullyCovers: boolean;
52
+ readonly missingInfo?: string;
53
+ }
54
+ /** Injected: infer each camera's parent (a model call in production). */
55
+ type InferParents = (cameras: readonly Camera[]) => Promise<readonly CameraParent[]>;
56
+ interface CameraTree {
57
+ readonly rootId: string;
58
+ readonly cameras: readonly Camera[];
59
+ /** Parent camera id, or null for the root. */
60
+ parentOf(cameraId: string): string | null;
61
+ }
62
+ /**
63
+ * Build the camera tree: infer parents, enforce that the first camera is the
64
+ * root, and that the parent relation is acyclic (a parent's footage cannot
65
+ * transitively depend on its descendant).
66
+ */
67
+ declare function buildCameraTree(cameras: readonly Camera[], infer: InferParents): Promise<CameraTree>;
68
+ /** Root→node camera order — the frame-inheritance chain for continuity. */
69
+ declare function resolveContinuityChain(tree: CameraTree, cameraId: string): string[];
70
+
71
+ /**
72
+ * Film-director composition — the ViMax pipeline form, re-expressed as a
73
+ * thin, fully-injected orchestrator. Every stage (write script, split shots,
74
+ * build cameras, infer parents, render a shot) is supplied by the caller and
75
+ * wired to Sailor primitives (`@nebutra/agents`, `@nebutra/reel`,
76
+ * `@nebutra/cinema` camera tree). This module owns only the sequencing and
77
+ * the cross-shot continuity guarantee — no model, no I/O.
78
+ */
79
+
80
+ interface FilmInput {
81
+ readonly idea: string;
82
+ }
83
+ interface FilmSteps {
84
+ readonly writeScript: (idea: string) => Promise<string>;
85
+ readonly splitShots: (script: string) => Promise<readonly string[]>;
86
+ readonly buildCameras: (shots: readonly string[]) => Promise<readonly Camera[]>;
87
+ readonly inferParents: (cameras: readonly Camera[]) => Promise<readonly CameraParent[]>;
88
+ readonly renderShot: (shot: string, index: number) => Promise<{
89
+ uri: string;
90
+ }>;
91
+ }
92
+ interface FilmResult {
93
+ readonly script: string;
94
+ readonly shots: readonly string[];
95
+ readonly cameraTree: CameraTree;
96
+ readonly clips: ReadonlyArray<{
97
+ shot: string;
98
+ uri: string;
99
+ }>;
100
+ }
101
+ /**
102
+ * Run idea → script → shots → cameras (acyclic continuity tree) → per-shot
103
+ * render → assembled clip list. Shots render in narrative order so a later
104
+ * shot can reuse an earlier one's frames for continuity.
105
+ */
106
+ declare function runFilmPipeline(input: FilmInput, steps: FilmSteps): Promise<FilmResult>;
107
+
108
+ /** Every cinema failure is a CinemaError (code + actionable suggestion). */
109
+ declare class CinemaError extends CapabilityError {
110
+ constructor(message: string, init: {
111
+ code: string;
112
+ suggestion: string;
113
+ cause?: unknown;
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Novel → compressed chunks → scene units — ViMax-distinct IP, re-expressed.
119
+ *
120
+ * Long-form adaptation: compress chapters (preserving plot/character) then
121
+ * segment into filmable scenes. Both steps are model calls — injected via a
122
+ * `CompleteFn` (the same injection shape `@nebutra/reel/storyboard` uses, so
123
+ * callers wire it to `@nebutra/agents` once). RAG retrieval, when used, is
124
+ * supplied by `@nebutra/knowledge-rag` through the same injection — not a
125
+ * hard dependency here.
126
+ */
127
+ /** Injected model call: prompt in, completion text out. */
128
+ type CompleteFn = (prompt: string) => Promise<string>;
129
+ /** Compress each chunk independently; order is preserved. */
130
+ declare function compressNovel(chunks: readonly string[], complete: CompleteFn): Promise<string[]>;
131
+ /**
132
+ * Extract ordered scene units from compressed text. The model is asked for a
133
+ * JSON array of scene strings; parsing tolerates surrounding prose but a
134
+ * non-array response is a hard error (a silent empty list would drop story).
135
+ */
136
+ declare function extractScenes(compressed: string, complete: CompleteFn): Promise<string[]>;
137
+
138
+ export { type BestFrame, type Camera, type CameraParent, type CameraTree, CinemaError, type CompleteFn, type FilmInput, type FilmResult, type FilmSteps, type FrameCandidate, type InferParents, type RankFrames, buildCameraTree, compressNovel, extractScenes, resolveContinuityChain, runFilmPipeline, selectBestFrame };
package/dist/index.js ADDED
@@ -0,0 +1,137 @@
1
+ // src/errors.ts
2
+ import { CapabilityError } from "@nebutra/capability-kit";
3
+ var CinemaError = class extends CapabilityError {
4
+ constructor(message, init) {
5
+ super(message, init, {
6
+ name: "CinemaError",
7
+ emptySuggestionFallback: "No suggestion was provided. This is a bug in @nebutra/cinema \u2014 report it with the failing stage."
8
+ });
9
+ }
10
+ };
11
+
12
+ // src/best-frame.ts
13
+ async function selectBestFrame(candidates, targetDescription, rank) {
14
+ if (candidates.length === 0) {
15
+ throw new CinemaError("No candidate frames to select from.", {
16
+ code: "CINEMA_NO_CANDIDATES",
17
+ suggestion: "Generate at least one candidate frame before ranking."
18
+ });
19
+ }
20
+ const { bestId, reason } = await rank(candidates, targetDescription);
21
+ const best = candidates.find((c) => c.id === bestId);
22
+ if (!best) {
23
+ throw new CinemaError(`Ranker chose "${bestId}", which is not among the candidates.`, {
24
+ code: "CINEMA_RANKER_CONTRACT",
25
+ suggestion: "The injected ranker must return one of the provided candidate ids; constrain its output schema."
26
+ });
27
+ }
28
+ return { best, reason };
29
+ }
30
+
31
+ // src/camera-tree.ts
32
+ import { wouldCreateCycle } from "@nebutra/graph-model";
33
+ async function buildCameraTree(cameras, infer) {
34
+ if (cameras.length === 0) {
35
+ throw new CinemaError("Cannot build a camera tree from zero cameras.", {
36
+ code: "CINEMA_NO_CAMERAS",
37
+ suggestion: "Pass at least one camera (the root)."
38
+ });
39
+ }
40
+ const parents = await infer(cameras);
41
+ const parentById = /* @__PURE__ */ new Map();
42
+ for (const p of parents) parentById.set(p.cameraId, p.parentCameraId);
43
+ const firstId = cameras[0]?.id;
44
+ if (parentById.get(firstId)) {
45
+ throw new CinemaError(`The first camera "${firstId}" must be the tree root.`, {
46
+ code: "CINEMA_BAD_ROOT",
47
+ suggestion: "Ensure the inference returns parentCameraId=null for the first camera."
48
+ });
49
+ }
50
+ const edges = [];
51
+ for (const p of parents) {
52
+ if (p.parentCameraId == null) continue;
53
+ if (wouldCreateCycle(edges, p.parentCameraId, p.cameraId)) {
54
+ throw new CinemaError(`Camera parent assignment is cyclic at "${p.cameraId}".`, {
55
+ code: "CINEMA_CYCLIC_TREE",
56
+ suggestion: "A parent camera must not transitively depend on its descendant; re-infer parents or break the cycle."
57
+ });
58
+ }
59
+ edges.push({ from: p.parentCameraId, to: p.cameraId });
60
+ }
61
+ return {
62
+ rootId: firstId,
63
+ cameras,
64
+ parentOf: (id) => parentById.get(id) ?? null
65
+ };
66
+ }
67
+ function resolveContinuityChain(tree, cameraId) {
68
+ const chain = [];
69
+ let cur = cameraId;
70
+ const seen = /* @__PURE__ */ new Set();
71
+ while (cur) {
72
+ if (seen.has(cur)) break;
73
+ seen.add(cur);
74
+ chain.push(cur);
75
+ cur = tree.parentOf(cur);
76
+ }
77
+ return chain.reverse();
78
+ }
79
+
80
+ // src/director.ts
81
+ async function runFilmPipeline(input, steps) {
82
+ const script = await steps.writeScript(input.idea);
83
+ const shots = await steps.splitShots(script);
84
+ const cameras = await steps.buildCameras(shots);
85
+ const cameraTree = await buildCameraTree(cameras, steps.inferParents);
86
+ const clips = [];
87
+ for (let i = 0; i < shots.length; i++) {
88
+ const shot = shots[i];
89
+ const { uri } = await steps.renderShot(shot, i);
90
+ clips.push({ shot, uri });
91
+ }
92
+ return { script, shots, cameraTree, clips };
93
+ }
94
+
95
+ // src/novel-segment.ts
96
+ async function compressNovel(chunks, complete) {
97
+ return Promise.all(
98
+ chunks.map(
99
+ (chunk) => complete(
100
+ `Compress this passage, preserving plot, character and emotional beats; output prose only:
101
+
102
+ ${chunk}`
103
+ )
104
+ )
105
+ );
106
+ }
107
+ async function extractScenes(compressed, complete) {
108
+ const raw = await complete(
109
+ `Split the following into a JSON array of self-contained, filmable scene descriptions in narrative order. Output ONLY the array:
110
+
111
+ ${compressed}`
112
+ );
113
+ const match = raw.match(/\[[\s\S]*\]/);
114
+ if (match) {
115
+ try {
116
+ const parsed = JSON.parse(match[0]);
117
+ if (Array.isArray(parsed) && parsed.every((s) => typeof s === "string")) {
118
+ return parsed;
119
+ }
120
+ } catch {
121
+ }
122
+ }
123
+ throw new CinemaError("Scene extraction did not return a JSON string array.", {
124
+ code: "CINEMA_SCENE_PARSE",
125
+ suggestion: "Constrain the model to emit a JSON array of strings (use a schema / response-format), or retry \u2014 output was not parseable."
126
+ });
127
+ }
128
+ export {
129
+ CinemaError,
130
+ buildCameraTree,
131
+ compressNovel,
132
+ extractScenes,
133
+ resolveContinuityChain,
134
+ runFilmPipeline,
135
+ selectBestFrame
136
+ };
137
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts","../src/best-frame.ts","../src/camera-tree.ts","../src/director.ts","../src/novel-segment.ts"],"sourcesContent":["import { CapabilityError } from \"@nebutra/capability-kit\";\n\n/** Every cinema failure is a CinemaError (code + actionable suggestion). */\nexport class CinemaError extends CapabilityError {\n constructor(message: string, init: { code: string; suggestion: string; cause?: unknown }) {\n super(message, init, {\n name: \"CinemaError\",\n emptySuggestionFallback:\n \"No suggestion was provided. This is a bug in @nebutra/cinema — \" +\n \"report it with the failing stage.\",\n });\n }\n}\n","/**\n * Consistency-ranked best-frame selection — ViMax-distinct IP, re-expressed.\n *\n * Generate N candidate frames, then have an MLLM rank them for character /\n * spatial / semantic consistency against the target description and pick the\n * best. The ranking is injected (a model call in production); this module\n * owns only the orchestration + validation invariants.\n */\n\nimport { CinemaError } from \"./errors\";\n\nexport interface FrameCandidate {\n readonly id: string;\n readonly uri: string;\n}\n\n/** Injected ranker: returns the winning candidate id + a human reason. */\nexport type RankFrames = (\n candidates: readonly FrameCandidate[],\n targetDescription: string,\n) => Promise<{ bestId: string; reason: string }>;\n\nexport interface BestFrame {\n readonly best: FrameCandidate;\n readonly reason: string;\n}\n\n/**\n * Select the most consistent frame. Throws if there are no candidates or if\n * the ranker returns an id that is not among them (a model contract breach\n * must never silently pick the wrong frame).\n */\nexport async function selectBestFrame(\n candidates: readonly FrameCandidate[],\n targetDescription: string,\n rank: RankFrames,\n): Promise<BestFrame> {\n if (candidates.length === 0) {\n throw new CinemaError(\"No candidate frames to select from.\", {\n code: \"CINEMA_NO_CANDIDATES\",\n suggestion: \"Generate at least one candidate frame before ranking.\",\n });\n }\n const { bestId, reason } = await rank(candidates, targetDescription);\n const best = candidates.find((c) => c.id === bestId);\n if (!best) {\n throw new CinemaError(`Ranker chose \"${bestId}\", which is not among the candidates.`, {\n code: \"CINEMA_RANKER_CONTRACT\",\n suggestion:\n \"The injected ranker must return one of the provided candidate ids; \" +\n \"constrain its output schema.\",\n });\n }\n return { best, reason };\n}\n","/**\n * Camera-continuity tree — the ViMax-distinct IP, re-expressed.\n *\n * A film is shot by multiple cameras; a parent camera's footage encompasses\n * its children's, so a child shot can inherit the parent's frames for\n * temporal/character continuity. The parent inference itself is a model call\n * (injected — never bound to a provider here). The structural invariants are\n * enforced locally with the neutral `@nebutra/graph-model` acyclic guard\n * (governance: reuse, don't re-implement cycle detection).\n */\n\nimport type { GraphEdge } from \"@nebutra/graph-model\";\nimport { wouldCreateCycle } from \"@nebutra/graph-model\";\nimport { CinemaError } from \"./errors\";\n\nexport interface Camera {\n readonly id: string;\n readonly shotIds: readonly string[];\n}\n\nexport interface CameraParent {\n readonly cameraId: string;\n /** Null = this camera is a root. */\n readonly parentCameraId: string | null;\n /** Which parent shot subsumes this camera (null at root). */\n readonly parentShotId: string | null;\n readonly fullyCovers: boolean;\n readonly missingInfo?: string;\n}\n\n/** Injected: infer each camera's parent (a model call in production). */\nexport type InferParents = (cameras: readonly Camera[]) => Promise<readonly CameraParent[]>;\n\nexport interface CameraTree {\n readonly rootId: string;\n readonly cameras: readonly Camera[];\n /** Parent camera id, or null for the root. */\n parentOf(cameraId: string): string | null;\n}\n\n/**\n * Build the camera tree: infer parents, enforce that the first camera is the\n * root, and that the parent relation is acyclic (a parent's footage cannot\n * transitively depend on its descendant).\n */\nexport async function buildCameraTree(\n cameras: readonly Camera[],\n infer: InferParents,\n): Promise<CameraTree> {\n if (cameras.length === 0) {\n throw new CinemaError(\"Cannot build a camera tree from zero cameras.\", {\n code: \"CINEMA_NO_CAMERAS\",\n suggestion: \"Pass at least one camera (the root).\",\n });\n }\n\n const parents = await infer(cameras);\n const parentById = new Map<string, string | null>();\n for (const p of parents) parentById.set(p.cameraId, p.parentCameraId);\n\n const firstId = cameras[0]?.id;\n if (parentById.get(firstId as string)) {\n throw new CinemaError(`The first camera \"${firstId}\" must be the tree root.`, {\n code: \"CINEMA_BAD_ROOT\",\n suggestion: \"Ensure the inference returns parentCameraId=null for the first camera.\",\n });\n }\n\n // Acyclic guard via graph-model: add parent→child edges incrementally.\n const edges: GraphEdge[] = [];\n for (const p of parents) {\n if (p.parentCameraId == null) continue;\n if (wouldCreateCycle(edges, p.parentCameraId, p.cameraId)) {\n throw new CinemaError(`Camera parent assignment is cyclic at \"${p.cameraId}\".`, {\n code: \"CINEMA_CYCLIC_TREE\",\n suggestion:\n \"A parent camera must not transitively depend on its descendant; \" +\n \"re-infer parents or break the cycle.\",\n });\n }\n edges.push({ from: p.parentCameraId, to: p.cameraId });\n }\n\n return {\n rootId: firstId as string,\n cameras,\n parentOf: (id) => parentById.get(id) ?? null,\n };\n}\n\n/** Root→node camera order — the frame-inheritance chain for continuity. */\nexport function resolveContinuityChain(tree: CameraTree, cameraId: string): string[] {\n const chain: string[] = [];\n let cur: string | null = cameraId;\n const seen = new Set<string>();\n while (cur) {\n if (seen.has(cur)) break; // defensive; tree is acyclic by construction\n seen.add(cur);\n chain.push(cur);\n cur = tree.parentOf(cur);\n }\n return chain.reverse();\n}\n","/**\n * Film-director composition — the ViMax pipeline form, re-expressed as a\n * thin, fully-injected orchestrator. Every stage (write script, split shots,\n * build cameras, infer parents, render a shot) is supplied by the caller and\n * wired to Sailor primitives (`@nebutra/agents`, `@nebutra/reel`,\n * `@nebutra/cinema` camera tree). This module owns only the sequencing and\n * the cross-shot continuity guarantee — no model, no I/O.\n */\n\nimport { buildCameraTree, type Camera, type CameraParent, type CameraTree } from \"./camera-tree\";\n\nexport interface FilmInput {\n readonly idea: string;\n}\n\nexport interface FilmSteps {\n readonly writeScript: (idea: string) => Promise<string>;\n readonly splitShots: (script: string) => Promise<readonly string[]>;\n readonly buildCameras: (shots: readonly string[]) => Promise<readonly Camera[]>;\n readonly inferParents: (cameras: readonly Camera[]) => Promise<readonly CameraParent[]>;\n readonly renderShot: (shot: string, index: number) => Promise<{ uri: string }>;\n}\n\nexport interface FilmResult {\n readonly script: string;\n readonly shots: readonly string[];\n readonly cameraTree: CameraTree;\n readonly clips: ReadonlyArray<{ shot: string; uri: string }>;\n}\n\n/**\n * Run idea → script → shots → cameras (acyclic continuity tree) → per-shot\n * render → assembled clip list. Shots render in narrative order so a later\n * shot can reuse an earlier one's frames for continuity.\n */\nexport async function runFilmPipeline(input: FilmInput, steps: FilmSteps): Promise<FilmResult> {\n const script = await steps.writeScript(input.idea);\n const shots = await steps.splitShots(script);\n const cameras = await steps.buildCameras(shots);\n const cameraTree = await buildCameraTree(cameras, steps.inferParents);\n\n const clips: Array<{ shot: string; uri: string }> = [];\n for (let i = 0; i < shots.length; i++) {\n const shot = shots[i] as string;\n const { uri } = await steps.renderShot(shot, i);\n clips.push({ shot, uri });\n }\n\n return { script, shots, cameraTree, clips };\n}\n","/**\n * Novel → compressed chunks → scene units — ViMax-distinct IP, re-expressed.\n *\n * Long-form adaptation: compress chapters (preserving plot/character) then\n * segment into filmable scenes. Both steps are model calls — injected via a\n * `CompleteFn` (the same injection shape `@nebutra/reel/storyboard` uses, so\n * callers wire it to `@nebutra/agents` once). RAG retrieval, when used, is\n * supplied by `@nebutra/knowledge-rag` through the same injection — not a\n * hard dependency here.\n */\n\nimport { CinemaError } from \"./errors\";\n\n/** Injected model call: prompt in, completion text out. */\nexport type CompleteFn = (prompt: string) => Promise<string>;\n\n/** Compress each chunk independently; order is preserved. */\nexport async function compressNovel(\n chunks: readonly string[],\n complete: CompleteFn,\n): Promise<string[]> {\n return Promise.all(\n chunks.map((chunk) =>\n complete(\n `Compress this passage, preserving plot, character and emotional ` +\n `beats; output prose only:\\n\\n${chunk}`,\n ),\n ),\n );\n}\n\n/**\n * Extract ordered scene units from compressed text. The model is asked for a\n * JSON array of scene strings; parsing tolerates surrounding prose but a\n * non-array response is a hard error (a silent empty list would drop story).\n */\nexport async function extractScenes(compressed: string, complete: CompleteFn): Promise<string[]> {\n const raw = await complete(\n `Split the following into a JSON array of self-contained, filmable ` +\n `scene descriptions in narrative order. Output ONLY the array:\\n\\n${compressed}`,\n );\n const match = raw.match(/\\[[\\s\\S]*\\]/);\n if (match) {\n try {\n const parsed: unknown = JSON.parse(match[0]);\n if (Array.isArray(parsed) && parsed.every((s) => typeof s === \"string\")) {\n return parsed as string[];\n }\n } catch {\n // fall through to the structured error below\n }\n }\n throw new CinemaError(\"Scene extraction did not return a JSON string array.\", {\n code: \"CINEMA_SCENE_PARSE\",\n suggestion:\n \"Constrain the model to emit a JSON array of strings (use a schema / \" +\n \"response-format), or retry — output was not parseable.\",\n });\n}\n"],"mappings":";AAAA,SAAS,uBAAuB;AAGzB,IAAM,cAAN,cAA0B,gBAAgB;AAAA,EAC/C,YAAY,SAAiB,MAA6D;AACxF,UAAM,SAAS,MAAM;AAAA,MACnB,MAAM;AAAA,MACN,yBACE;AAAA,IAEJ,CAAC;AAAA,EACH;AACF;;;ACoBA,eAAsB,gBACpB,YACA,mBACA,MACoB;AACpB,MAAI,WAAW,WAAW,GAAG;AAC3B,UAAM,IAAI,YAAY,uCAAuC;AAAA,MAC3D,MAAM;AAAA,MACN,YAAY;AAAA,IACd,CAAC;AAAA,EACH;AACA,QAAM,EAAE,QAAQ,OAAO,IAAI,MAAM,KAAK,YAAY,iBAAiB;AACnE,QAAM,OAAO,WAAW,KAAK,CAAC,MAAM,EAAE,OAAO,MAAM;AACnD,MAAI,CAAC,MAAM;AACT,UAAM,IAAI,YAAY,iBAAiB,MAAM,yCAAyC;AAAA,MACpF,MAAM;AAAA,MACN,YACE;AAAA,IAEJ,CAAC;AAAA,EACH;AACA,SAAO,EAAE,MAAM,OAAO;AACxB;;;AC1CA,SAAS,wBAAwB;AAiCjC,eAAsB,gBACpB,SACA,OACqB;AACrB,MAAI,QAAQ,WAAW,GAAG;AACxB,UAAM,IAAI,YAAY,iDAAiD;AAAA,MACrE,MAAM;AAAA,MACN,YAAY;AAAA,IACd,CAAC;AAAA,EACH;AAEA,QAAM,UAAU,MAAM,MAAM,OAAO;AACnC,QAAM,aAAa,oBAAI,IAA2B;AAClD,aAAW,KAAK,QAAS,YAAW,IAAI,EAAE,UAAU,EAAE,cAAc;AAEpE,QAAM,UAAU,QAAQ,CAAC,GAAG;AAC5B,MAAI,WAAW,IAAI,OAAiB,GAAG;AACrC,UAAM,IAAI,YAAY,qBAAqB,OAAO,4BAA4B;AAAA,MAC5E,MAAM;AAAA,MACN,YAAY;AAAA,IACd,CAAC;AAAA,EACH;AAGA,QAAM,QAAqB,CAAC;AAC5B,aAAW,KAAK,SAAS;AACvB,QAAI,EAAE,kBAAkB,KAAM;AAC9B,QAAI,iBAAiB,OAAO,EAAE,gBAAgB,EAAE,QAAQ,GAAG;AACzD,YAAM,IAAI,YAAY,0CAA0C,EAAE,QAAQ,MAAM;AAAA,QAC9E,MAAM;AAAA,QACN,YACE;AAAA,MAEJ,CAAC;AAAA,IACH;AACA,UAAM,KAAK,EAAE,MAAM,EAAE,gBAAgB,IAAI,EAAE,SAAS,CAAC;AAAA,EACvD;AAEA,SAAO;AAAA,IACL,QAAQ;AAAA,IACR;AAAA,IACA,UAAU,CAAC,OAAO,WAAW,IAAI,EAAE,KAAK;AAAA,EAC1C;AACF;AAGO,SAAS,uBAAuB,MAAkB,UAA4B;AACnF,QAAM,QAAkB,CAAC;AACzB,MAAI,MAAqB;AACzB,QAAM,OAAO,oBAAI,IAAY;AAC7B,SAAO,KAAK;AACV,QAAI,KAAK,IAAI,GAAG,EAAG;AACnB,SAAK,IAAI,GAAG;AACZ,UAAM,KAAK,GAAG;AACd,UAAM,KAAK,SAAS,GAAG;AAAA,EACzB;AACA,SAAO,MAAM,QAAQ;AACvB;;;ACnEA,eAAsB,gBAAgB,OAAkB,OAAuC;AAC7F,QAAM,SAAS,MAAM,MAAM,YAAY,MAAM,IAAI;AACjD,QAAM,QAAQ,MAAM,MAAM,WAAW,MAAM;AAC3C,QAAM,UAAU,MAAM,MAAM,aAAa,KAAK;AAC9C,QAAM,aAAa,MAAM,gBAAgB,SAAS,MAAM,YAAY;AAEpE,QAAM,QAA8C,CAAC;AACrD,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACrC,UAAM,OAAO,MAAM,CAAC;AACpB,UAAM,EAAE,IAAI,IAAI,MAAM,MAAM,WAAW,MAAM,CAAC;AAC9C,UAAM,KAAK,EAAE,MAAM,IAAI,CAAC;AAAA,EAC1B;AAEA,SAAO,EAAE,QAAQ,OAAO,YAAY,MAAM;AAC5C;;;AChCA,eAAsB,cACpB,QACA,UACmB;AACnB,SAAO,QAAQ;AAAA,IACb,OAAO;AAAA,MAAI,CAAC,UACV;AAAA,QACE;AAAA;AAAA,EACkC,KAAK;AAAA,MACzC;AAAA,IACF;AAAA,EACF;AACF;AAOA,eAAsB,cAAc,YAAoB,UAAyC;AAC/F,QAAM,MAAM,MAAM;AAAA,IAChB;AAAA;AAAA,EACsE,UAAU;AAAA,EAClF;AACA,QAAM,QAAQ,IAAI,MAAM,aAAa;AACrC,MAAI,OAAO;AACT,QAAI;AACF,YAAM,SAAkB,KAAK,MAAM,MAAM,CAAC,CAAC;AAC3C,UAAI,MAAM,QAAQ,MAAM,KAAK,OAAO,MAAM,CAAC,MAAM,OAAO,MAAM,QAAQ,GAAG;AACvE,eAAO;AAAA,MACT;AAAA,IACF,QAAQ;AAAA,IAER;AAAA,EACF;AACA,QAAM,IAAI,YAAY,wDAAwD;AAAA,IAC5E,MAAM;AAAA,IACN,YACE;AAAA,EAEJ,CAAC;AACH;","names":[]}
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@nebutra/cinema",
3
+ "version": "0.2.0",
4
+ "description": "Agentic film-production IP: acyclic camera-continuity tree, consistency-ranked best-frame selection, novel→scene/event segmentation, film-director composition. Pure + dependency-injected; sits on reel/agents/graph-model.",
5
+ "private": false,
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "nebutra": {
9
+ "featureId": "cinema",
10
+ "category": "ai",
11
+ "summary": "Multi-agent film-production orchestration over the reel media graph"
12
+ },
13
+ "main": "./src/index.ts",
14
+ "types": "./src/index.ts",
15
+ "exports": {
16
+ ".": "./src/index.ts"
17
+ },
18
+ "dependencies": {
19
+ "@nebutra/capability-kit": "0.2.0",
20
+ "@nebutra/graph-model": "0.2.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^22.19.15",
24
+ "tsup": "^8.5.1",
25
+ "typescript": "^5.9.3",
26
+ "vitest": "^4.0.18"
27
+ },
28
+ "homepage": "https://github.com/Nebutra/Nebutra-Sailor/tree/main/packages/ai/cinema#readme",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/Nebutra/Nebutra-Sailor.git",
32
+ "directory": "packages/ai/cinema"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/Nebutra/Nebutra-Sailor/issues"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "build": "tsup",
42
+ "test": "vitest run",
43
+ "typecheck": "tsc --noEmit"
44
+ }
45
+ }
@@ -0,0 +1,141 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { selectBestFrame } from "../best-frame";
3
+ import { buildCameraTree, resolveContinuityChain } from "../camera-tree";
4
+ import { runFilmPipeline } from "../director";
5
+ import { CinemaError } from "../errors";
6
+ import { compressNovel, extractScenes } from "../novel-segment";
7
+
8
+ const cams = [
9
+ { id: "c0", shotIds: ["s0", "s1"] },
10
+ { id: "c1", shotIds: ["s2"] },
11
+ { id: "c2", shotIds: ["s3"] },
12
+ ];
13
+
14
+ describe("buildCameraTree", () => {
15
+ it("builds an acyclic tree with the first camera as root", async () => {
16
+ const tree = await buildCameraTree(cams, async () => [
17
+ { cameraId: "c0", parentCameraId: null, parentShotId: null, fullyCovers: true },
18
+ { cameraId: "c1", parentCameraId: "c0", parentShotId: "s0", fullyCovers: true },
19
+ { cameraId: "c2", parentCameraId: "c1", parentShotId: "s2", fullyCovers: false },
20
+ ]);
21
+ expect(tree.rootId).toBe("c0");
22
+ expect(tree.parentOf("c1")).toBe("c0");
23
+ });
24
+
25
+ it("rejects a cyclic parent assignment (graph-model guard)", async () => {
26
+ await expect(
27
+ buildCameraTree(cams, async () => [
28
+ { cameraId: "c0", parentCameraId: "c2", parentShotId: "s3", fullyCovers: true },
29
+ { cameraId: "c1", parentCameraId: "c0", parentShotId: "s0", fullyCovers: true },
30
+ { cameraId: "c2", parentCameraId: "c1", parentShotId: "s2", fullyCovers: true },
31
+ ]),
32
+ ).rejects.toBeInstanceOf(CinemaError);
33
+ });
34
+
35
+ it("rejects when the first camera is not a root", async () => {
36
+ await expect(
37
+ buildCameraTree(cams, async () => [
38
+ { cameraId: "c0", parentCameraId: "c1", parentShotId: "s2", fullyCovers: true },
39
+ { cameraId: "c1", parentCameraId: null, parentShotId: null, fullyCovers: true },
40
+ { cameraId: "c2", parentCameraId: "c1", parentShotId: "s2", fullyCovers: true },
41
+ ]),
42
+ ).rejects.toThrow(/root/i);
43
+ });
44
+
45
+ it("resolveContinuityChain returns the root→node inheritance order", async () => {
46
+ const tree = await buildCameraTree(cams, async () => [
47
+ { cameraId: "c0", parentCameraId: null, parentShotId: null, fullyCovers: true },
48
+ { cameraId: "c1", parentCameraId: "c0", parentShotId: "s0", fullyCovers: true },
49
+ { cameraId: "c2", parentCameraId: "c1", parentShotId: "s2", fullyCovers: true },
50
+ ]);
51
+ expect(resolveContinuityChain(tree, "c2")).toEqual(["c0", "c1", "c2"]);
52
+ expect(resolveContinuityChain(tree, "c0")).toEqual(["c0"]);
53
+ });
54
+ });
55
+
56
+ describe("selectBestFrame", () => {
57
+ const cands = [
58
+ { id: "a", uri: "data:a" },
59
+ { id: "b", uri: "data:b" },
60
+ ];
61
+ it("returns the ranked best and its reason", async () => {
62
+ const r = await selectBestFrame(cands, "Alice on the left", async () => ({
63
+ bestId: "b",
64
+ reason: "best character consistency",
65
+ }));
66
+ expect(r.best.id).toBe("b");
67
+ expect(r.reason).toMatch(/consistency/);
68
+ });
69
+ it("throws CinemaError on empty candidates", async () => {
70
+ await expect(
71
+ selectBestFrame([], "x", async () => ({ bestId: "", reason: "" })),
72
+ ).rejects.toBeInstanceOf(CinemaError);
73
+ });
74
+ it("throws when the ranker picks an unknown id", async () => {
75
+ await expect(
76
+ selectBestFrame(cands, "x", async () => ({ bestId: "zzz", reason: "" })),
77
+ ).rejects.toThrow(/not among/i);
78
+ });
79
+ });
80
+
81
+ describe("novel segmentation", () => {
82
+ it("compressNovel preserves chunk order", async () => {
83
+ const out = await compressNovel(["AAA", "BBB"], async (p) => `c:${p.slice(-3)}`);
84
+ expect(out).toEqual(["c:AAA", "c:BBB"]);
85
+ });
86
+ it("extractScenes parses a JSON array, tolerating prose wrapping", async () => {
87
+ const scenes = await extractScenes("novel", async () => 'noise ["scene 1","scene 2"] tail');
88
+ expect(scenes).toEqual(["scene 1", "scene 2"]);
89
+ });
90
+ it("extractScenes throws CinemaError on unparseable output", async () => {
91
+ await expect(extractScenes("x", async () => "no array here")).rejects.toBeInstanceOf(
92
+ CinemaError,
93
+ );
94
+ });
95
+ });
96
+
97
+ describe("runFilmPipeline", () => {
98
+ it("threads idea→script→shots→cameras→frames in order and returns the assembly", async () => {
99
+ const calls: string[] = [];
100
+ const result = await runFilmPipeline(
101
+ { idea: "a dog learns to fly" },
102
+ {
103
+ writeScript: async (idea) => {
104
+ calls.push("script");
105
+ return `SCRIPT(${idea})`;
106
+ },
107
+ splitShots: async (script) => {
108
+ calls.push("shots");
109
+ return [`${script}#0`, `${script}#1`];
110
+ },
111
+ buildCameras: async (shots) => {
112
+ calls.push("cameras");
113
+ return shots.map((_, i) => ({ id: `c${i}`, shotIds: [`s${i}`] }));
114
+ },
115
+ inferParents: async (cameras) =>
116
+ cameras.map((c, i) => ({
117
+ cameraId: c.id,
118
+ parentCameraId: i === 0 ? null : `c${i - 1}`,
119
+ parentShotId: i === 0 ? null : `s${i - 1}`,
120
+ fullyCovers: true,
121
+ })),
122
+ renderShot: async (shot) => {
123
+ calls.push(`render:${shot}`);
124
+ return { uri: `mp4:${shot}` };
125
+ },
126
+ },
127
+ );
128
+ expect(calls).toEqual([
129
+ "script",
130
+ "shots",
131
+ "cameras",
132
+ "render:SCRIPT(a dog learns to fly)#0",
133
+ "render:SCRIPT(a dog learns to fly)#1",
134
+ ]);
135
+ expect(result.clips.map((c) => c.uri)).toEqual([
136
+ "mp4:SCRIPT(a dog learns to fly)#0",
137
+ "mp4:SCRIPT(a dog learns to fly)#1",
138
+ ]);
139
+ expect(result.cameraTree.rootId).toBe("c0");
140
+ });
141
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Consistency-ranked best-frame selection — ViMax-distinct IP, re-expressed.
3
+ *
4
+ * Generate N candidate frames, then have an MLLM rank them for character /
5
+ * spatial / semantic consistency against the target description and pick the
6
+ * best. The ranking is injected (a model call in production); this module
7
+ * owns only the orchestration + validation invariants.
8
+ */
9
+
10
+ import { CinemaError } from "./errors";
11
+
12
+ export interface FrameCandidate {
13
+ readonly id: string;
14
+ readonly uri: string;
15
+ }
16
+
17
+ /** Injected ranker: returns the winning candidate id + a human reason. */
18
+ export type RankFrames = (
19
+ candidates: readonly FrameCandidate[],
20
+ targetDescription: string,
21
+ ) => Promise<{ bestId: string; reason: string }>;
22
+
23
+ export interface BestFrame {
24
+ readonly best: FrameCandidate;
25
+ readonly reason: string;
26
+ }
27
+
28
+ /**
29
+ * Select the most consistent frame. Throws if there are no candidates or if
30
+ * the ranker returns an id that is not among them (a model contract breach
31
+ * must never silently pick the wrong frame).
32
+ */
33
+ export async function selectBestFrame(
34
+ candidates: readonly FrameCandidate[],
35
+ targetDescription: string,
36
+ rank: RankFrames,
37
+ ): Promise<BestFrame> {
38
+ if (candidates.length === 0) {
39
+ throw new CinemaError("No candidate frames to select from.", {
40
+ code: "CINEMA_NO_CANDIDATES",
41
+ suggestion: "Generate at least one candidate frame before ranking.",
42
+ });
43
+ }
44
+ const { bestId, reason } = await rank(candidates, targetDescription);
45
+ const best = candidates.find((c) => c.id === bestId);
46
+ if (!best) {
47
+ throw new CinemaError(`Ranker chose "${bestId}", which is not among the candidates.`, {
48
+ code: "CINEMA_RANKER_CONTRACT",
49
+ suggestion:
50
+ "The injected ranker must return one of the provided candidate ids; " +
51
+ "constrain its output schema.",
52
+ });
53
+ }
54
+ return { best, reason };
55
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Camera-continuity tree — the ViMax-distinct IP, re-expressed.
3
+ *
4
+ * A film is shot by multiple cameras; a parent camera's footage encompasses
5
+ * its children's, so a child shot can inherit the parent's frames for
6
+ * temporal/character continuity. The parent inference itself is a model call
7
+ * (injected — never bound to a provider here). The structural invariants are
8
+ * enforced locally with the neutral `@nebutra/graph-model` acyclic guard
9
+ * (governance: reuse, don't re-implement cycle detection).
10
+ */
11
+
12
+ import type { GraphEdge } from "@nebutra/graph-model";
13
+ import { wouldCreateCycle } from "@nebutra/graph-model";
14
+ import { CinemaError } from "./errors";
15
+
16
+ export interface Camera {
17
+ readonly id: string;
18
+ readonly shotIds: readonly string[];
19
+ }
20
+
21
+ export interface CameraParent {
22
+ readonly cameraId: string;
23
+ /** Null = this camera is a root. */
24
+ readonly parentCameraId: string | null;
25
+ /** Which parent shot subsumes this camera (null at root). */
26
+ readonly parentShotId: string | null;
27
+ readonly fullyCovers: boolean;
28
+ readonly missingInfo?: string;
29
+ }
30
+
31
+ /** Injected: infer each camera's parent (a model call in production). */
32
+ export type InferParents = (cameras: readonly Camera[]) => Promise<readonly CameraParent[]>;
33
+
34
+ export interface CameraTree {
35
+ readonly rootId: string;
36
+ readonly cameras: readonly Camera[];
37
+ /** Parent camera id, or null for the root. */
38
+ parentOf(cameraId: string): string | null;
39
+ }
40
+
41
+ /**
42
+ * Build the camera tree: infer parents, enforce that the first camera is the
43
+ * root, and that the parent relation is acyclic (a parent's footage cannot
44
+ * transitively depend on its descendant).
45
+ */
46
+ export async function buildCameraTree(
47
+ cameras: readonly Camera[],
48
+ infer: InferParents,
49
+ ): Promise<CameraTree> {
50
+ if (cameras.length === 0) {
51
+ throw new CinemaError("Cannot build a camera tree from zero cameras.", {
52
+ code: "CINEMA_NO_CAMERAS",
53
+ suggestion: "Pass at least one camera (the root).",
54
+ });
55
+ }
56
+
57
+ const parents = await infer(cameras);
58
+ const parentById = new Map<string, string | null>();
59
+ for (const p of parents) parentById.set(p.cameraId, p.parentCameraId);
60
+
61
+ const firstId = cameras[0]?.id;
62
+ if (parentById.get(firstId as string)) {
63
+ throw new CinemaError(`The first camera "${firstId}" must be the tree root.`, {
64
+ code: "CINEMA_BAD_ROOT",
65
+ suggestion: "Ensure the inference returns parentCameraId=null for the first camera.",
66
+ });
67
+ }
68
+
69
+ // Acyclic guard via graph-model: add parent→child edges incrementally.
70
+ const edges: GraphEdge[] = [];
71
+ for (const p of parents) {
72
+ if (p.parentCameraId == null) continue;
73
+ if (wouldCreateCycle(edges, p.parentCameraId, p.cameraId)) {
74
+ throw new CinemaError(`Camera parent assignment is cyclic at "${p.cameraId}".`, {
75
+ code: "CINEMA_CYCLIC_TREE",
76
+ suggestion:
77
+ "A parent camera must not transitively depend on its descendant; " +
78
+ "re-infer parents or break the cycle.",
79
+ });
80
+ }
81
+ edges.push({ from: p.parentCameraId, to: p.cameraId });
82
+ }
83
+
84
+ return {
85
+ rootId: firstId as string,
86
+ cameras,
87
+ parentOf: (id) => parentById.get(id) ?? null,
88
+ };
89
+ }
90
+
91
+ /** Root→node camera order — the frame-inheritance chain for continuity. */
92
+ export function resolveContinuityChain(tree: CameraTree, cameraId: string): string[] {
93
+ const chain: string[] = [];
94
+ let cur: string | null = cameraId;
95
+ const seen = new Set<string>();
96
+ while (cur) {
97
+ if (seen.has(cur)) break; // defensive; tree is acyclic by construction
98
+ seen.add(cur);
99
+ chain.push(cur);
100
+ cur = tree.parentOf(cur);
101
+ }
102
+ return chain.reverse();
103
+ }