Skip to content

Commit 96cd71f

Browse files
committed
feat(api): add script-to-shot planner baseline for sprint-05
1 parent a3ddae9 commit 96cd71f

7 files changed

Lines changed: 279 additions & 7 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from "vitest";
2+
import { InMemorySceneShotPlanner } from "./scene-shot-planner";
3+
4+
describe("scene shot planner", () => {
5+
it("is idempotent for unchanged scene payload", () => {
6+
const planner = new InMemorySceneShotPlanner();
7+
const first = planner.plan("p1", [{ sceneId: "s1", content: "角色走进房间。镜头推近。" }]);
8+
const second = planner.plan("p1", [{ sceneId: "s1", content: "角色走进房间。镜头推近。" }]);
9+
10+
expect(first[0].changed).toBe(true);
11+
expect(second[0].changed).toBe(false);
12+
expect(second[0].version).toBe(first[0].version);
13+
expect(second[0].shotDrafts.map((x) => x.shotId)).toEqual(first[0].shotDrafts.map((x) => x.shotId));
14+
});
15+
16+
it("increments version and replaces old shots when forced regenerate", () => {
17+
const planner = new InMemorySceneShotPlanner();
18+
const first = planner.plan("p1", [{ sceneId: "s2", content: "角色回头。" }]);
19+
const forced = planner.plan("p1", [{ sceneId: "s2", content: "角色回头。" }], new Set(["s2"]));
20+
21+
expect(forced[0].changed).toBe(true);
22+
expect(forced[0].version).toBe(first[0].version + 1);
23+
expect(forced[0].replacedShotIds).toEqual(first[0].shotDrafts.map((x) => x.shotId));
24+
});
25+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import crypto from "node:crypto";
2+
3+
export interface ScenePlanInput {
4+
sceneId: string;
5+
content: string;
6+
targetShotCount?: number;
7+
}
8+
9+
export interface ShotDraft {
10+
shotId: string;
11+
prompt: string;
12+
sourceSceneId: string;
13+
index: number;
14+
}
15+
16+
export interface ScenePlanRecord {
17+
sceneId: string;
18+
version: number;
19+
fingerprint: string;
20+
shotDrafts: ShotDraft[];
21+
updatedAt: string;
22+
}
23+
24+
export interface PlannedSceneResult extends ScenePlanRecord {
25+
changed: boolean;
26+
replacedShotIds: string[];
27+
}
28+
29+
function normalizeText(text: string): string {
30+
return text
31+
.replace(/\s+/g, " ")
32+
.trim();
33+
}
34+
35+
function computeFingerprint(input: ScenePlanInput): string {
36+
const normalized = normalizeText(input.content);
37+
const payload = `${input.sceneId}::${normalized}::${input.targetShotCount ?? "auto"}`;
38+
return crypto.createHash("sha256").update(payload).digest("hex");
39+
}
40+
41+
function splitBeats(content: string): string[] {
42+
const normalized = normalizeText(content);
43+
const chunks = normalized
44+
.split(/[.!?;]+/)
45+
.map((part) => part.trim())
46+
.filter(Boolean);
47+
if (chunks.length > 0) return chunks;
48+
return [normalized || "scene beat"];
49+
}
50+
51+
function normalizeCount(value: number | undefined, fallback: number): number {
52+
if (!value || value <= 0) return fallback;
53+
return Math.max(1, Math.min(8, Math.floor(value)));
54+
}
55+
56+
function buildShotDrafts(sceneId: string, version: number, content: string, targetShotCount?: number): ShotDraft[] {
57+
const beats = splitBeats(content);
58+
const count = normalizeCount(targetShotCount, Math.min(4, Math.max(1, beats.length)));
59+
60+
const drafts: ShotDraft[] = [];
61+
for (let i = 0; i < count; i += 1) {
62+
const beat = beats[i % beats.length];
63+
drafts.push({
64+
shotId: `scene-${sceneId}-v${version}-s${i + 1}`,
65+
prompt: `${beat} [scene:${sceneId} shot:${i + 1}]`,
66+
sourceSceneId: sceneId,
67+
index: i + 1,
68+
});
69+
}
70+
return drafts;
71+
}
72+
73+
export class InMemorySceneShotPlanner {
74+
private readonly byProject = new Map<string, Map<string, ScenePlanRecord>>();
75+
76+
plan(projectId: string, scenes: ScenePlanInput[], regenerateSceneIds: Set<string> = new Set()): PlannedSceneResult[] {
77+
const sceneMap = this.byProject.get(projectId) ?? new Map<string, ScenePlanRecord>();
78+
this.byProject.set(projectId, sceneMap);
79+
80+
const results: PlannedSceneResult[] = [];
81+
82+
for (const input of scenes) {
83+
const previous = sceneMap.get(input.sceneId);
84+
const fingerprint = computeFingerprint(input);
85+
const shouldRegenerate = regenerateSceneIds.has(input.sceneId);
86+
const unchanged = previous && previous.fingerprint === fingerprint && !shouldRegenerate;
87+
88+
if (unchanged && previous) {
89+
results.push({
90+
...previous,
91+
changed: false,
92+
replacedShotIds: [],
93+
});
94+
continue;
95+
}
96+
97+
const version = (previous?.version ?? 0) + 1;
98+
const next: ScenePlanRecord = {
99+
sceneId: input.sceneId,
100+
version,
101+
fingerprint,
102+
shotDrafts: buildShotDrafts(input.sceneId, version, input.content, input.targetShotCount),
103+
updatedAt: new Date().toISOString(),
104+
};
105+
sceneMap.set(input.sceneId, next);
106+
107+
results.push({
108+
...next,
109+
changed: true,
110+
replacedShotIds: previous ? previous.shotDrafts.map((shot) => shot.shotId) : [],
111+
});
112+
}
113+
114+
return results;
115+
}
116+
117+
get(projectId: string, sceneId: string): ScenePlanRecord | undefined {
118+
return this.byProject.get(projectId)?.get(sceneId);
119+
}
120+
}

apps/api/src/http/app.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
PromptTemplateVersionConflictError,
3737
} from "../domain/template/prompt-template";
3838
import { InMemoryUsageGuardrail } from "../domain/usage/usage-guardrail";
39+
import { InMemorySceneShotPlanner } from "../domain/script/scene-shot-planner";
3940
import { ProviderAdapterRegistry } from "../providers/adapter-registry";
4041
import { type ProviderName } from "../providers/types";
4142
import { InMemoryProviderKeyStore } from "../security/provider-key-store";
@@ -75,6 +76,7 @@ interface PromptTemplateRefs {
7576
export interface ApiState {
7677
readonly projects: Map<string, Map<string, ShotRecord>>;
7778
readonly shotToProject: Map<string, string>;
79+
readonly shotToScene: Map<string, string>;
7880
readonly taskRepo: InMemoryGenerationTaskRepository;
7981
readonly callbackEventStore: InMemoryCallbackEventStore;
8082
readonly deadLetterStore: InMemoryDeadLetterStore;
@@ -85,6 +87,7 @@ export interface ApiState {
8587
readonly providerProfileStore: InMemoryProjectProviderProfileStore;
8688
readonly promptTemplateStore: InMemoryPromptTemplateStore;
8789
readonly usageGuardrail: InMemoryUsageGuardrail;
90+
readonly sceneShotPlanner: InMemorySceneShotPlanner;
8891
}
8992

9093
const importSchema = z.object({
@@ -95,6 +98,22 @@ const importSchema = z.object({
9598
}),
9699
),
97100
});
101+
const scriptScenePlanSchema = z
102+
.object({
103+
scenes: z
104+
.array(
105+
z
106+
.object({
107+
sceneId: z.string().min(1),
108+
content: z.string().min(1),
109+
targetShotCount: z.number().int().positive().max(8).optional(),
110+
})
111+
.strict(),
112+
)
113+
.min(1),
114+
regenerateSceneIds: z.array(z.string().min(1)).optional(),
115+
})
116+
.strict();
98117

99118
const generateSchema = z.object({
100119
provider: z.enum(["runway", "kling"]).optional(),
@@ -252,6 +271,7 @@ export function createApiState(): ApiState {
252271
return {
253272
projects: new Map(),
254273
shotToProject: new Map(),
274+
shotToScene: new Map(),
255275
taskRepo: new InMemoryGenerationTaskRepository(),
256276
callbackEventStore: new InMemoryCallbackEventStore(),
257277
deadLetterStore: new InMemoryDeadLetterStore(),
@@ -262,6 +282,7 @@ export function createApiState(): ApiState {
262282
providerProfileStore: new InMemoryProjectProviderProfileStore(),
263283
promptTemplateStore: new InMemoryPromptTemplateStore(),
264284
usageGuardrail: new InMemoryUsageGuardrail(),
285+
sceneShotPlanner: new InMemorySceneShotPlanner(),
265286
};
266287
}
267288

@@ -309,6 +330,7 @@ export function createApp(state = createApiState()) {
309330
mjAssets: [],
310331
});
311332
state.shotToProject.set(input.id, projectId);
333+
state.shotToScene.delete(input.id);
312334
}
313335

314336
state.projects.set(projectId, existing);
@@ -320,6 +342,64 @@ export function createApp(state = createApiState()) {
320342
});
321343
});
322344

345+
app.post("/projects/:id/script-scenes/plan", (req: Request, res: Response) => {
346+
const parsed = scriptScenePlanSchema.safeParse(req.body);
347+
if (!parsed.success) return res.status(400).json({ message: "Invalid request", errors: parsed.error.issues });
348+
349+
const projectId = routeId(req);
350+
const projectShots = state.projects.get(projectId) ?? new Map<string, ShotRecord>();
351+
state.projects.set(projectId, projectShots);
352+
state.providerProfileStore.ensure(projectId);
353+
354+
const planned = state.sceneShotPlanner.plan(projectId, parsed.data.scenes, new Set(parsed.data.regenerateSceneIds ?? []));
355+
356+
for (const scene of planned) {
357+
for (const oldShotId of scene.replacedShotIds) {
358+
projectShots.delete(oldShotId);
359+
state.shotToProject.delete(oldShotId);
360+
state.shotToScene.delete(oldShotId);
361+
}
362+
for (const draft of scene.shotDrafts) {
363+
const base = createShotCandidateState(draft.shotId);
364+
projectShots.set(draft.shotId, {
365+
...base,
366+
shotId: draft.shotId,
367+
status: "draft",
368+
prompt: draft.prompt,
369+
promptTemplateRefs: {},
370+
mjPrompts: [],
371+
mjAssets: [],
372+
});
373+
state.shotToProject.set(draft.shotId, projectId);
374+
state.shotToScene.set(draft.shotId, scene.sceneId);
375+
}
376+
appendActivityEvent(state, {
377+
projectId,
378+
type: "script_scene_planned",
379+
source: "user",
380+
payload: {
381+
sceneId: scene.sceneId,
382+
version: scene.version,
383+
shotCount: scene.shotDrafts.length,
384+
changed: scene.changed,
385+
},
386+
});
387+
}
388+
389+
return res.status(200).json({
390+
projectId,
391+
plannedSceneCount: planned.length,
392+
generatedShotCount: planned.reduce((acc, scene) => acc + scene.shotDrafts.length, 0),
393+
plans: planned.map((scene) => ({
394+
sceneId: scene.sceneId,
395+
version: scene.version,
396+
changed: scene.changed,
397+
updatedAt: scene.updatedAt,
398+
shotDrafts: scene.shotDrafts,
399+
})),
400+
});
401+
});
402+
323403
app.post("/providers/callbacks", (req: Request, res: Response) => {
324404
const parsed = providerCallbackSchema.safeParse(req.body);
325405
if (!parsed.success) return res.status(400).json({ message: "Invalid request", errors: parsed.error.issues });
@@ -585,6 +665,7 @@ export function createApp(state = createApiState()) {
585665
const projectId = routeId(req);
586666
const shots = [...(state.projects.get(projectId)?.values() ?? [])].map((shot) => ({
587667
shotId: shot.shotId,
668+
sceneId: state.shotToScene.get(shot.shotId) ?? null,
588669
status: shot.status,
589670
prompt: shot.prompt,
590671
promptTemplateRefs: shot.promptTemplateRefs,
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import request from "supertest";
2+
import { describe, expect, it } from "vitest";
3+
import { createApp } from "./app";
4+
5+
describe("script to shot planner api", () => {
6+
it("creates plans from scenes and keeps unchanged scenes idempotent", async () => {
7+
const app = createApp();
8+
9+
const first = await request(app).post("/projects/p-s5/script-scenes/plan").send({
10+
scenes: [
11+
{ sceneId: "scene-1", content: "男主推门进入酒吧。镜头跟随到吧台。" },
12+
{ sceneId: "scene-2", content: "女主回头,沉默两秒后开口。", targetShotCount: 3 },
13+
],
14+
});
15+
expect(first.status).toBe(200);
16+
expect(first.body.plannedSceneCount).toBe(2);
17+
expect(first.body.generatedShotCount).toBeGreaterThanOrEqual(4);
18+
19+
const firstScene1ShotIds = first.body.plans[0].shotDrafts.map((shot: { shotId: string }) => shot.shotId);
20+
expect(first.body.plans[0].version).toBe(1);
21+
22+
const second = await request(app).post("/projects/p-s5/script-scenes/plan").send({
23+
scenes: [{ sceneId: "scene-1", content: "男主推门进入酒吧。镜头跟随到吧台。" }],
24+
});
25+
expect(second.status).toBe(200);
26+
expect(second.body.plans[0].changed).toBe(false);
27+
expect(second.body.plans[0].version).toBe(1);
28+
expect(second.body.plans[0].shotDrafts.map((shot: { shotId: string }) => shot.shotId)).toEqual(firstScene1ShotIds);
29+
30+
const regenerated = await request(app).post("/projects/p-s5/script-scenes/plan").send({
31+
scenes: [{ sceneId: "scene-1", content: "男主推门进入酒吧。镜头跟随到吧台。" }],
32+
regenerateSceneIds: ["scene-1"],
33+
});
34+
expect(regenerated.status).toBe(200);
35+
expect(regenerated.body.plans[0].changed).toBe(true);
36+
expect(regenerated.body.plans[0].version).toBe(2);
37+
38+
const list = await request(app).get("/projects/p-s5/shots");
39+
expect(list.status).toBe(200);
40+
expect(list.body.shots.some((shot: { sceneId: string | null }) => shot.sceneId === "scene-1")).toBe(true);
41+
expect(list.body.shots.every((shot: { shotId: string }) => !firstScene1ShotIds.includes(shot.shotId))).toBe(true);
42+
});
43+
});

apps/api/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export * from "./domain/quality/quality-score";
1111
export * from "./domain/provider/provider-profile";
1212
export * from "./domain/template/prompt-template";
1313
export * from "./domain/usage/usage-guardrail";
14+
export * from "./domain/script/scene-shot-planner";
1415
export * from "./providers/types";
1516
export * from "./providers/runway-adapter";
1617
export * from "./providers/kling-adapter";

docs/spec/execution-ledger.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ Use this file as the progress ledger during delivery.
44

55
## Snapshot
66
- Last updated: 2026-02-23
7-
- Current phase: Sprint 05 planning
8-
- Current sprint: Sprint 05 (not started)
7+
- Current phase: Sprint 05 execution
8+
- Current sprint: Sprint 05 (active)
99
- Overall status: in_progress
1010

1111
## Milestones
@@ -20,6 +20,7 @@ Use this file as the progress ledger during delivery.
2020
| M6 | Sprint 04 execution start | done | Eng | 2026-02-22 | Sprint 04 issues #33-#40 implemented |
2121
| M7 | Sprint 04 closeout | done | Eng | 2026-02-23 | Integrated validation completed and issue set closed |
2222
| M8 | Sprint 05 planning kickoff | done | Eng | 2026-02-23 | Sprint 05 backlog and issue drafts prepared |
23+
| M9 | Sprint 05 execution start | in_progress | Eng | 2026-02-23 | S5-01 implemented locally, remote issue state sync pending |
2324

2425
## Decision Log
2526
| Date | Decision | Owner | Rationale |
@@ -37,8 +38,8 @@ Use this file as the progress ledger during delivery.
3738
| Tooling positioning | done | Keep adapter contracts aligned |
3839
| Master roadmap | done | Execute by phase gates |
3940
| Executable task list | done | 14 issues created and assigned |
40-
| Sprint backlog | done | Sprint 04 Issue #33-#40 closed |
41-
| Engineering implementation | in_progress | Create Sprint 05 issues and start #41 |
41+
| Sprint backlog | in_progress | Sprint 05 Issue #41-#48 active |
42+
| Engineering implementation | in_progress | Continue with Sprint 05 #42 after remote issue sync |
4243

4344
## Change Log (execution)
4445
| Date | Change | Commit / Link |
@@ -117,3 +118,4 @@ Use this file as the progress ledger during delivery.
117118
| 2026-02-23 | Add Sprint 04 integrated scale validation test flow (#40) | `e37e46b` |
118119
| 2026-02-23 | Sprint 04 scope fully completed in code/tests; Issue #40 remote close pending CLI auth | pending remote update |
119120
| 2026-02-23 | Prepare Sprint 05 backlog, issue drafts, and execution order | pending commit |
121+
| 2026-02-23 | Implement Script-to-Shot planner API baseline with idempotent scene planning tests (#41) | pending commit |

docs/sprints/sprint-05-issue-index.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
## Status Snapshot (2026-02-23)
44
- Closed: none
5-
- In progress: none
6-
- Todo: #41, #42, #43, #44, #45, #46, #47, #48
5+
- In progress: #41 (local implemented, remote issue creation pending auth)
6+
- Todo: #42, #43, #44, #45, #46, #47, #48
77

88
## Issue Links
99
41. [#41 [BE] Script-to-shot planner API baseline](https://github.com/demshine/cineweave-platform/issues/41)
@@ -16,4 +16,4 @@
1616
48. [#48 [QA] Sprint 05 integrated creator flow validation](https://github.com/demshine/cineweave-platform/issues/48)
1717

1818
## Next Task
19-
- Create Sprint 05 issues #41-#48, assign to `@echowang1`, and start #41.
19+
- Sync remote issue states, then start #42 Midjourney character consistency registry.

0 commit comments

Comments
 (0)