{"record":{"id":"26ca688bf22d6c7c","repo":"jamiepine/voicebox","slug":"story-item-not-found-or-invalid-split-point","errorCode":null,"errorMessage":"Story item not found or invalid split point","messagePattern":"Story item not found or invalid split point","errorType":"http","errorClass":"HTTPException","httpStatus":404,"severity":"error","filePath":"backend/routes/stories.py","lineNumber":178,"sourceCode":"):\n    \"\"\"Set a story item's per-clip volume (linear gain, 0.0–2.0).\"\"\"\n    item = await stories.update_story_item_volume(story_id, item_id, data, db)\n    if item is None:\n        raise HTTPException(status_code=404, detail=\"Story item not found\")\n    return item\n\n\n@router.post(\"/stories/{story_id}/items/{item_id}/split\", response_model=list[models.StoryItemDetail])\nasync def split_story_item(\n    story_id: str,\n    item_id: str,\n    data: models.StoryItemSplit,\n    db: Session = Depends(get_db),\n):\n    \"\"\"Split a story item at a given time, creating two clips.\"\"\"\n    items = await stories.split_story_item(story_id, item_id, data, db)\n    if items is None:\n        raise HTTPException(status_code=404, detail=\"Story item not found or invalid split point\")\n    return items\n\n\n@router.post(\"/stories/{story_id}/items/{item_id}/duplicate\", response_model=models.StoryItemDetail)\nasync def duplicate_story_item(\n    story_id: str,\n    item_id: str,\n    db: Session = Depends(get_db),\n):\n    \"\"\"Duplicate a story item.\"\"\"\n    item = await stories.duplicate_story_item(story_id, item_id, db)\n    if item is None:\n        raise HTTPException(status_code=404, detail=\"Story item not found\")\n    return item\n\n\n@router.put(\"/stories/{story_id}/items/{item_id}/version\", response_model=models.StoryItemDetail)\nasync def set_story_item_version(","sourceCodeStart":160,"sourceCodeEnd":196,"githubUrl":"https://github.com/jamiepine/voicebox/blob/51f49dea198384b4eb6087b72c17057c6eb1c1cd/backend/routes/stories.py#L160-L196","documentation":"Raised (HTTP 404) by POST /stories/{story_id}/items/{item_id}/split when the underlying stories.split_story_item service returns None. The service returns None for two conflated reasons: (a) the story item, its story, or its parent generation row cannot be found, or (b) the supplied split_time_ms is out of range — split_time_ms must satisfy 0 < split_time_ms < effective_duration_ms (effective_duration_ms = generation.duration_ms - trim_start_ms - trim_end_ms). Because the route collapses both into a 404, an invalid split point looks indistinguishable from a missing item to the client.","triggerScenarios":"POST /stories/{story_id}/items/{item_id}/split with a split_time_ms of 0, a negative value, or a value >= the clip's effective (trimmed) duration. Also when item_id does not exist under the given story_id, or the item's generation_id has been deleted.","commonSituations":"Client computes split time from the wrong baseline (absolute timeline vs. clip-relative timeline), splits at the exact clip end, or operates on an item that was already split/deleted by a concurrent request. UI passing the raw playhead position instead of the offset into the trimmed clip.","solutions":["Confirm split_time_ms is strictly between 0 and effective_duration_ms using the item's trim_start_ms/trim_end_ms and generation.duration before calling.","Verify the item still exists under story_id (GET /stories/{story_id}) before splitting — a stale item_id from a prior split/duplicate causes this.","If splitting at the clip-relative midpoint, compute split_time_ms = effective_duration_ms // 2, never effective_duration_ms itself.","Treat HTTP 404 here as ambiguous: re-fetch the item; if it exists, the split point was invalid (file as a 400/422 suggestion upstream)."],"exampleFix":"// before\nconst splitAt = item.start_time_ms + playheadMs; // wrong: absolute, not clip-relative\nawait api.splitItem(storyId, itemId, { split_time_ms: splitAt });\n\n// after\nconst effective = item.duration_ms - (item.trim_start_ms ?? 0) - (item.trim_end_ms ?? 0);\nif (playheadMs <= 0 || playheadMs >= effective) {\n  throw new Error(`split point must be within (0, ${effective})`);\n}\nawait api.splitItem(storyId, itemId, { split_time_ms: playheadMs });","handlingStrategy":"validation","validationCode":"function clampSplitPoint(item, splitTimeMs) {\n  const trimStart = item.trim_start_ms ?? 0;\n  const trimEnd = item.trim_end_ms ?? 0;\n  const effective = Math.round(item.duration_ms) - trimStart - trimEnd;\n  if (!Number.isFinite(splitTimeMs) || splitTimeMs <= 0 || splitTimeMs >= effective) {\n    return { ok: false, effective, reason: `split_time_ms must be within (0, ${effective})` };\n  }\n  return { ok: true, effective };\n}\n// call before POST .../split\nconst check = clampSplitPoint(item, payload.split_time_ms);\nif (!check.ok) throw new Error(check.reason);","typeGuard":"function isValidSplitPayload(item, p) {\n  if (!item || typeof item.duration_ms !== 'number') return false;\n  if (typeof p?.split_time_ms !== 'number') return false;\n  const effective = item.duration_ms - (item.trim_start_ms ?? 0) - (item.trim_end_ms ?? 0);\n  return p.split_time_ms > 0 && p.split_time_ms < effective;\n}","tryCatchPattern":"// none — validate pre-flight; a 404 here is ambiguous (missing item vs. bad split),\n// so re-fetch the item to disambiguate rather than catching.\nif (!isValidSplitPayload(item, payload)) { warnUser(); return; }","preventionTips":["Always compute split_time_ms relative to the trimmed clip, not the absolute playhead.","Re-fetch the item right before splitting to avoid stale ids from concurrent edits.","Disable the split control when effective duration is <= 1ms."],"tags":["api","validation","stories","fastapi","status-code-mismatch"],"backgroundTag":null,"analyzedSha":"51f49dea198384b4eb6087b72c17057c6eb1c1cd","analyzedAt":"2026-08-12T16:51:42.824Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}