Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions app/services/story_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,138 @@ def patch_story_project(
return write_story_library(workspace_dir, next_library, base_revision=expected)


def _story_id_token(value: Any) -> str:
return str(value or "").strip()


def _index_by_id(items: list[Any], token: str) -> int:
for index, item in enumerate(items):
if isinstance(item, dict) and _story_id_token(item.get("id")) == token:
return index
return -1


def _require_music_row(project: dict[str, Any], cue_id: str, candidate_id: str) -> tuple[dict, list, int, list, int]:
music = dict(project.get("music") or {})
cues = list(music.get("cues") or [])
cue_index = _index_by_id(cues, cue_id)
if cue_index < 0:
raise KeyError(cue_id)
cue = dict(cues[cue_index])
candidates = list(cue.get("candidates") or [])
candidate_index = _index_by_id(candidates, candidate_id)
if candidate_index < 0:
raise KeyError(candidate_id)
return music, cues, cue_index, candidates, candidate_index


def _apply_song_candidate_patch(
candidate: dict[str, Any],
*,
project_id: str,
cue_id: str,
candidate_id: str,
source: str,
filename: str,
status: str,
duration_seconds: float | int | None,
task_id: str | None,
root_task_id: str | None,
job_id: str | None,
) -> dict[str, Any]:
patched = dict(candidate)
patched["id"] = candidate_id
patched["source"] = str(source or "")
patched["name"] = str(filename or patched.get("name") or "")
patched["status"] = status
if duration_seconds is not None:
patched["durationSeconds"] = duration_seconds
if task_id:
patched["taskId"] = task_id
if root_task_id:
patched["rootTaskId"] = root_task_id
provenance = dict(patched.get("provenance") or {})
provenance.update({
"projectId": project_id,
"cueId": cue_id,
"candidateId": candidate_id,
})
if job_id:
provenance["jobId"] = job_id
if task_id:
provenance["taskId"] = task_id
if root_task_id:
provenance["rootTaskId"] = root_task_id
patched["provenance"] = provenance
return patched


def attach_story_song_candidate(
workspace_dir: str,
*,
project_id: str,
cue_id: str,
candidate_id: str,
source: str,
filename: str,
status: str = "ready",
base_revision: int,
duration_seconds: float | int | None = None,
task_id: str | None = None,
root_task_id: str | None = None,
job_id: str | None = None,
) -> dict[str, Any]:
"""CAS-patch one pending Story song row by project/cue/candidate IDs.

Operates only on the library file inside ``workspace_dir``. A matching
candidate in another folder is never visible here.
"""
token_project = _story_id_token(project_id)
token_cue = _story_id_token(cue_id)
token_candidate = _story_id_token(candidate_id)
if not token_project or not token_cue or not token_candidate:
raise ValueError("Story song attach requires project, cue and candidate IDs")
if status not in {"pending", "ready", "failed"}:
raise ValueError("Story song status must be pending, ready or failed")
with _STORY_LIBRARY_LOCK:
current = read_story_library(workspace_dir)
expected = _base_revision(base_revision)
if expected != current["revision"]:
raise StoryLibraryRevisionConflict(expected, int(current["revision"]))
project = current["projects"].get(token_project)
if not isinstance(project, dict):
raise KeyError(token_project)
music, cues, cue_index, candidates, candidate_index = _require_music_row(
project, token_cue, token_candidate,
)
cue = dict(cues[cue_index])
candidates[candidate_index] = _apply_song_candidate_patch(
dict(candidates[candidate_index]),
project_id=token_project,
cue_id=token_cue,
candidate_id=token_candidate,
source=source,
filename=filename,
status=status,
duration_seconds=duration_seconds,
task_id=task_id,
root_task_id=root_task_id,
job_id=job_id,
)
cue["candidates"] = candidates
cue["selectedCandidateId"] = token_candidate
cues[cue_index] = cue
music["cues"] = cues
music["selectedCandidateId"] = token_candidate
next_project = dict(project)
next_project["music"] = music
return write_story_library(
workspace_dir,
{**current, "projects": {**current["projects"], token_project: next_project}},
base_revision=expected,
)


def delete_story_project(
workspace_dir: str,
project_id: str,
Expand Down
6 changes: 6 additions & 0 deletions docs/development/DOMAIN_MODEL_AND_ASSET_PROVENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ Every command and relationship propagates opaque IDs. Names, selected labels
and `v1`-style display versions are never used to recover an identity that was
already returned by the previous step.

Story Lab song versions follow the same rule: identity is
`StoryMusicCandidate.id` (`song-…`), not the display `v1` / `v2` integer. The
pending row is saved to the Story library before generate starts so a client
close can recover the WAV from a sidecar `candidate_id`. See
[STORY_SONG_IDENTITY.md](STORY_SONG_IDENTITY.md).

## Storage transition

Existing workspace folders and `.meta.json` files remain readable. New APIs
Expand Down
47 changes: 47 additions & 0 deletions docs/development/STORY_SONG_IDENTITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Story song identity

Status: accepted for Block 3 (client persist-before-generate). Server-side
`generate-music` attach is a follow-up and is **not** in this slice.

A song version **is** `StoryMusicCandidate.id` (`song-…`). Display `v1` / `v2`
is denormalized and must never be used to recover identity.

## Persist before compute

1. Resolve the **open Story** first (`targetStoryId` / current Story Lab
project), then a unique title. Do not invent another project.
2. Resolve the cue by **cueId**. Use title only when no ID is supplied. A stale
title fails. Never pick an unrelated sole cue because a title was given.
3. Mint `candidateId = storyId('song')` and `version = nextMusicCandidateVersion(...)`
**before** calling generate.
4. Save a **pending** row on that cue (`status: 'pending'`, empty source,
provenance with `projectId` / `cueId` / `candidateId` / `songVersion`) with
Story library CAS **before** compute starts.
5. Call generate with provenance
`{ actor, capability, project_id, cue_id, candidate_id, song_version }`.
6. On success, patch **the same id**: source, filename, task/job ids,
`status: 'ready'`. Do not mint a second id.
7. On failure, mark `status: 'failed'` and keep the id for retry lineage.

Client close during generate is recoverable because the pending row is already
on disk. `loadWorkspace` reattaches a WAV whose sidecar/output carries the
matching `candidate_id`, including rows marked `failed` after a client timeout
once the sidecar exists. Recovery writes the `ready` patch to the Story
library immediately; it must not only update the in-memory snapshot.

## Staging

Staging a videoclip requires a **ready** candidate by id. Pending and failed
rows are refused. The synthetic cue id `story-song` is refused unless the
caller passed that exact id.

## Normalize

`normalizeMusicCandidate` keeps pending/failed rows that already have a stable
id, even with an empty `source`. It never remints an existing id on load.
Incomplete preview rows with no id and no source stay dropped.

## Out of scope

This slice does not edit `_launch_runtime.py`, `useStore.ts` or
`agentActions.ts`. Server-side attach during `generate-music` is the next PR.
123 changes: 123 additions & 0 deletions tests/test_story_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from app.services.story_library import (
MAX_STORY_PROJECTS,
StoryLibraryRevisionConflict,
attach_story_song_candidate,
normalize_story_library,
delete_story_project,
patch_story_project,
Expand Down Expand Up @@ -106,6 +107,128 @@ def test_invalid_existing_json_is_not_silently_overwritten(self):
with self.assertRaises(json.JSONDecodeError):
read_story_library(directory)

def test_attach_story_song_candidate_patches_pending_row(self):
with tempfile.TemporaryDirectory() as directory:
initial = write_story_library(directory, {
"activeId": "story-a",
"projects": {
"story-a": {
"id": "story-a",
"title": "Workspace A",
"music": {
"cues": [{
"id": "cue-a",
"title": "Theme",
"candidates": [{
"id": "song-a",
"status": "pending",
"source": "",
"name": "",
}],
}],
},
},
},
}, base_revision=0)
saved = attach_story_song_candidate(
directory,
project_id="story-a",
cue_id="cue-a",
candidate_id="song-a",
source="/api/v1/file/theme.wav?workspace=a",
filename="theme.wav",
status="ready",
base_revision=initial["revision"],
task_id="task-1",
)
candidate = saved["projects"]["story-a"]["music"]["cues"][0]["candidates"][0]
self.assertEqual(saved["revision"], 2)
self.assertEqual(candidate["id"], "song-a")
self.assertEqual(candidate["status"], "ready")
self.assertEqual(candidate["name"], "theme.wav")
self.assertEqual(candidate["provenance"]["candidateId"], "song-a")

def test_attach_story_song_candidate_cas_conflict_keeps_pending_row(self):
with tempfile.TemporaryDirectory() as directory:
first = write_story_library(directory, {
"projects": {
"story-a": {
"id": "story-a",
"music": {
"cues": [{
"id": "cue-a",
"candidates": [{"id": "song-a", "status": "pending", "source": ""}],
}],
},
},
},
}, base_revision=0)
attach_story_song_candidate(
directory,
project_id="story-a",
cue_id="cue-a",
candidate_id="song-a",
source="/api/v1/file/theme.wav",
filename="theme.wav",
base_revision=first["revision"],
)
with self.assertRaises(StoryLibraryRevisionConflict):
attach_story_song_candidate(
directory,
project_id="story-a",
cue_id="cue-a",
candidate_id="song-a",
source="/api/v1/file/other.wav",
filename="other.wav",
base_revision=first["revision"],
)
candidate = read_story_library(directory)["projects"]["story-a"]["music"]["cues"][0]["candidates"][0]
self.assertEqual(candidate["name"], "theme.wav")
self.assertEqual(candidate["id"], "song-a")

def test_attach_story_song_candidate_is_isolated_by_workspace_dir(self):
with tempfile.TemporaryDirectory() as workspace_a, tempfile.TemporaryDirectory() as workspace_b:
write_story_library(workspace_a, {
"projects": {
"story-a": {
"id": "story-a",
"music": {
"cues": [{
"id": "cue-a",
"candidates": [{"id": "song-a", "status": "pending", "source": ""}],
}],
},
},
},
}, base_revision=0)
write_story_library(workspace_b, {
"projects": {
"story-b": {
"id": "story-b",
"music": {
"cues": [{
"id": "cue-b",
"candidates": [{"id": "song-b", "status": "pending", "source": ""}],
}],
},
},
},
}, base_revision=0)
with self.assertRaises(KeyError):
attach_story_song_candidate(
workspace_b,
project_id="story-a",
cue_id="cue-a",
candidate_id="song-a",
source="/api/v1/file/stolen.wav",
filename="stolen.wav",
base_revision=1,
)
self.assertEqual(
read_story_library(workspace_a)["projects"]["story-a"]["music"]["cues"][0]["candidates"][0]["source"],
"",
)

def test_project_limit_is_enforced(self):
value = {
"projects": {
Expand Down
1 change: 1 addition & 0 deletions ui/src/api/director.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export async function generateMusic(params: {
production_id?: string
cue_id?: string
candidate_id?: string
song_version?: string
command?: Record<string, string>
}
}): Promise<{
Expand Down
8 changes: 8 additions & 0 deletions ui/src/api/stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ export interface StoryMusicCandidateRequest {
reference_audio_filename?: string
instrumental?: boolean
workspace?: string
provenance?: {
actor?: 'user' | 'wizard' | 'system' | 'unknown'
capability?: string
project_id?: string
cue_id?: string
candidate_id?: string
song_version?: string
}
}

export async function startStoryMusicCandidatesJob(
Expand Down
Loading