// Endpoint data — single source of truth for every /docs/* page.
// Each entry feeds the EndpointPage template.

const ENDPOINTS = {
  'video/best-frames': {
    group: 'video',
    title: 'best-frames',
    path: '/v1/video/best-frames',
    method: 'POST',
    short: 'Return the clearest, most informative frames in a video — ranked.',
    when: 'Use when you need still images for thumbnails, RAG indexing, or visual summaries — and want the API to pick the best ones rather than sampling at fixed intervals.',
    price: '$0.035 / video min',
    params: [
      ['media_url',     'string',         'Public or signed URL to the source video.', true],
      ['max_frames',    'integer',        'Maximum number of frames to return (default 8, max 64).'],
      ['start',         'number',         'Window start in seconds. Default 0.'],
      ['end',           'number',         'Window end in seconds. Default media duration.'],
      ['min_gap',       'number',         'Minimum seconds between selected frames (default 4).'],
      ['quality',       '"low"|"std"|"high"', 'Processing quality. Default "std".'],
    ],
    responseFields: [
      ['frames',        'Frame[]',        'Ordered list of selected frames, best first.'],
      ['frames[].t',    'number',         'Timestamp of the frame in seconds.'],
      ['frames[].url',  'string',         'Signed URL to the JPEG (24h).'],
      ['frames[].score','number',         'Composite quality + informativeness score (0–1).'],
      ['media_seconds', 'number',         'Duration of the analyzed media in seconds.'],
      ['price_usd',     'number',         'Final price for this call.'],
    ],
    requestExample: {
      'media_url': 'https://cdn.momentiq.dev/demo/lecture-08.mp4',
      'max_frames': 8,
      'min_gap': 6,
    },
    responseExample: {
      'endpoint': 'video/best-frames',
      'media_seconds': 1742,
      'frames': [
        { 't': 312.4, 'score': 0.94 },
        { 't': 618.0, 'score': 0.91 },
        { 't': 901.7, 'score': 0.88 },
      ],
      'price_usd': 1.02,
    },
    related: ['video/best-frame-near','video/dedupe-frames','video/thumbnail-score','video/text-frames'],
  },

  'video/best-frame-near': {
    group: 'video', title: 'best-frame-near', path: '/v1/video/best-frame-near', method: 'POST',
    short: 'Return the single best still image near a target timestamp.',
    when: 'Use to grab a clean cover frame near an event you already know about — a laugh, a slide change, a chapter mark.',
    price: '$0.015 / video min',
    params: [
      ['media_url','string','Public or signed URL to the source video.', true],
      ['t','number','Target timestamp in seconds.', true],
      ['window','number','Search window in seconds around t (default 4).'],
      ['min_score','number','Reject if best score below this threshold (default 0.4).'],
    ],
    responseFields: [
      ['frame.t','number','Timestamp of the chosen frame.'],
      ['frame.url','string','Signed URL to the JPEG.'],
      ['frame.score','number','Composite quality score (0–1).'],
      ['price_usd','number','Final price for this call.'],
    ],
    requestExample: { 'media_url':'https://cdn.../ep-42.mp4', 't': 862.4, 'window': 3 },
    responseExample: { 'frame': { 't': 861.8, 'score': 0.86 }, 'price_usd': 0.015 },
    related: ['video/best-frames','video/thumbnail-score','audio/detect-laughter'],
  },

  'video/text-frames': {
    group: 'video', title: 'text-frames', path: '/v1/video/text-frames', method: 'POST',
    short: 'Find frames that contain readable text — slides, whiteboards, dashboards, terminals.',
    when: 'Use to extract slide-aligned material from lectures, demos, screen recordings — perfect for RAG indexing or building lecture notes.',
    price: '$0.06 / video min',
    params: [
      ['media_url','string','Public or signed URL to the source video.', true],
      ['min_text_density','number','Minimum fraction of frame area covered by text (0–1, default 0.05).'],
      ['dedupe','boolean','Drop near-duplicate frames (default true).'],
      ['ocr','boolean','Include OCR text in response (default false).'],
    ],
    responseFields: [
      ['frames','Frame[]','List of frames with readable text.'],
      ['frames[].t','number','Timestamp.'],
      ['frames[].text','string','OCR text (if ocr=true).'],
      ['frames[].kind','"slide"|"whiteboard"|"screen"|"other"','Inferred surface type.'],
    ],
    requestExample: { 'media_url':'https://cdn.../lecture.mp4', 'ocr': true, 'dedupe': true },
    responseExample: {
      'frames': [
        { 't': 184.2, 'kind': 'slide', 'text': 'Lecture 8 — Differentiation under the integral sign' },
        { 't': 642.8, 'kind': 'slide', 'text': 'Leibniz rule: d/dx ∫_a(x)^b(x) f(x,t) dt = …' },
      ],
      'price_usd': 1.74,
    },
    related: ['video/dedupe-frames','audio/semantic-chunks','timeline/merge'],
  },

  'video/detect-cuts': {
    group: 'video', title: 'detect-cuts', path: '/v1/video/detect-cuts', method: 'POST',
    short: 'Detect hard visual changes — shot boundaries, slide swaps, scene jumps.',
    when: 'Use to chapter long content, build scrubbable previews, or feed video chapters into your RAG index.',
    price: '$0.012 / video min',
    params: [
      ['media_url','string','Public or signed URL to the source video.', true],
      ['sensitivity','number','How aggressive cut detection is (0–1, default 0.55).'],
      ['min_segment','number','Minimum seconds between adjacent cuts (default 0.4).'],
    ],
    responseFields: [
      ['cuts','number[]','Timestamps of cuts in seconds.'],
      ['segments','{start,end}[]','Convenience segments derived from cuts.'],
    ],
    requestExample: { 'media_url':'https://cdn.../keynote.mp4', 'sensitivity': 0.6 },
    responseExample: {
      'cuts': [12.4, 47.1, 102.6, 188.3, 244.9],
      'segments': [{ 'start':0,'end':12.4 }, { 'start':12.4,'end':47.1 }],
      'price_usd': 0.20,
    },
    related: ['video/text-frames','audio/suggest-cut-points','timeline/merge'],
  },

  'video/dedupe-frames': {
    group: 'video', title: 'dedupe-frames', path: '/v1/video/dedupe-frames', method: 'POST',
    short: 'Remove visually-repetitive frames from a list — keep one representative per cluster.',
    when: 'Use after best-frames or text-frames to ensure unique, non-redundant outputs for thumbnails or notes.',
    price: '$0.015 / video min',
    params: [
      ['frames','Frame[]','Frames to dedupe.', true],
      ['threshold','number','Similarity threshold (0–1, default 0.92).'],
    ],
    responseFields: [
      ['frames','Frame[]','Deduplicated frames, ordered by timestamp.'],
      ['removed','integer','Number of frames removed.'],
    ],
    requestExample: { 'frames': [{ 't': 12.4 }, { 't': 12.6 }, { 't': 88.0 }], 'threshold': 0.9 },
    responseExample: { 'frames': [{ 't': 12.4 }, { 't': 88.0 }], 'removed': 1, 'price_usd': 0.015 },
    related: ['video/best-frames','video/text-frames','video/thumbnail-score'],
  },

  'video/thumbnail-score': {
    group: 'video', title: 'thumbnail-score', path: '/v1/video/thumbnail-score', method: 'POST',
    short: 'Score frames for use as thumbnails — face presence, expression, contrast, focal interest.',
    when: 'Use after best-frames to pick the most click-worthy thumbnail from a candidate set.',
    price: '$0.025 / video min',
    params: [
      ['frames','Frame[]','Frames to score.', true],
      ['target','"clip"|"video"|"chapter"','Tunes the scorer for context.'],
    ],
    responseFields: [
      ['frames','Frame[]','Frames sorted by descending thumbnail_score.'],
      ['frames[].thumbnail_score','number','0–1 thumbnail suitability.'],
      ['frames[].reasons','string[]','Short why-list ("face_present", "high_contrast").'],
    ],
    requestExample: { 'frames':[{ 't': 312 },{ 't': 901 }], 'target': 'clip' },
    responseExample: {
      'frames': [
        { 't': 901, 'thumbnail_score': 0.91, 'reasons': ['face_present','direct_gaze','high_contrast'] },
        { 't': 312, 'thumbnail_score': 0.62, 'reasons': ['high_contrast'] },
      ],
      'price_usd': 0.025,
    },
    related: ['video/best-frames','video/best-frame-near','video/dedupe-frames'],
  },

  'video/clip-window': {
    group: 'video', title: 'clip-window', path: '/v1/video/clip-window', method: 'POST',
    short: 'Cut a clip from start to end. Returns a new media URL.',
    when: 'Use after suggest-ranges or your own logic to render a real, playable clip.',
    price: '$0.035 / clip min',
    params: [
      ['media_url','string','Source media URL.', true],
      ['start','number','Start in seconds.', true],
      ['end','number','End in seconds.', true],
      ['format','"mp4"|"mov"|"mp3"','Output format. Default mp4.'],
    ],
    responseFields: [
      ['url','string','Signed URL to the rendered clip.'],
      ['duration','number','Clip duration in seconds.'],
    ],
    requestExample: { 'media_url':'https://cdn.../ep-42.mp4', 'start': 862, 'end': 894 },
    responseExample: { 'url':'https://cdn.momentiq.dev/clips/abc123.mp4', 'duration': 32, 'price_usd': 0.019 },
    related: ['video/clip-near','timeline/suggest-ranges','audio/isolate-speech'],
  },

  'video/clip-near': {
    group: 'video', title: 'clip-near', path: '/v1/video/clip-near', method: 'POST',
    short: 'Cut a clip centered on a timestamp, with smart boundary alignment to silence and cuts.',
    when: 'Use to turn a single moment (a laugh, a quote) into a render-ready clip without writing boundary logic.',
    price: '$0.035 / clip min',
    params: [
      ['media_url','string','Source media URL.', true],
      ['t','number','Target timestamp in seconds.', true],
      ['target_duration','number','Desired clip length in seconds (default 30).'],
      ['snap','boolean','Snap boundaries to nearby silence/cuts (default true).'],
    ],
    responseFields: [
      ['url','string','Signed URL to the rendered clip.'],
      ['start','number','Final start.'],
      ['end','number','Final end.'],
    ],
    requestExample: { 'media_url':'https://cdn.../ep-42.mp4', 't': 862.4, 'target_duration': 30 },
    responseExample: { 'url':'https://cdn.../clips/x.mp4', 'start': 850.1, 'end': 882.6, 'price_usd': 0.019 },
    related: ['video/clip-window','audio/detect-silence','timeline/find-nearest'],
  },

  'audio/detect-silence': {
    group: 'audio', title: 'detect-silence', path: '/v1/audio/detect-silence', method: 'POST',
    short: 'Find silence regions — natural cut points, breath pauses, gaps.',
    when: 'Use to align edits to natural pauses, trim dead air, or feed boundaries into clip-near.',
    price: '$0.006 / audio min',
    params: [
      ['media_url','string','Public or signed URL to the source media.', true],
      ['method','"vad"|"db"','Detection method. Default "vad".'],
      ['min_duration','number','Minimum silence/no-speech length in seconds (default 0.5).'],
      ['speech_threshold','number','VAD speech threshold from 0-1. Default 0.5.'],
      ['threshold_db','number','Optional dB threshold for dB mode. Default -35.'],
    ],
    responseFields: [
      ['segments','TimelineSegment[]','Silence or no-speech regions in seconds.'],
    ],
    requestExample: { 'media_url':'https://cdn.../ep-42.mp4', 'method':'vad', 'min_duration': 0.5, 'speech_threshold': 0.5 },
    responseExample: {
      'segments':[{ 'kind':'silence','start':12.1,'end':12.6 },{ 'kind':'silence','start':88.4,'end':89.0 }],
      'price_usd': 0.017,
    },
    related: ['audio/detect-energy','timeline/merge','timeline/suggest-ranges'],
  },

  'audio/detect-laughter': {
    group: 'audio', title: 'detect-laughter', path: '/v1/audio/detect-laughter', method: 'POST',
    short: 'Detect likely laughter candidate moments with confidence scores.',
    when: 'Use to find likely funny parts of a podcast or stream — feed candidates into clip-near to ship clips.',
    price: '$0.035 / audio min',
    params: [
      ['media_url','string','Public or signed URL to the source media.', true],
      ['threshold','number','Candidate sensitivity threshold from 0-1. Lower is more sensitive. Default 0.15.'],
      ['min_duration','number','Minimum laughter candidate length in seconds (default 0.2).'],
      ['speech_filter','boolean','Reject candidates that look like spoken words rather than laughter. Default true.'],
    ],
    responseFields: [
      ['segments','TimelineSegment[]','Likely laughter candidate events with start/end, score, and confidence.'],
    ],
    requestExample: { 'media_url':'https://cdn.../ep-42.mp4', 'threshold': 0.15, 'speech_filter': true },
    responseExample: {
      'segments':[
        { 'kind':'laughter_candidate','start':862.4,'end':864.2,'confidence':0.94 },
        { 'kind':'laughter_candidate','start':948.1,'end':949.0,'confidence':0.81 },
      ],
      'price_usd': 0.21,
    },
    related: ['video/clip-near','audio/detect-silence','timeline/suggest-ranges'],
  },

  'audio/detect-energy': {
    group: 'audio', title: 'detect-energy', path: '/v1/audio/detect-energy', method: 'POST',
    short: 'Score audio energy over time — find the loud, intense, or quiet parts.',
    when: 'Use to surface emotionally-charged moments, music drops, intense Q&A, or for an energy heatmap.',
    price: '$0.008 / audio min',
    params: [
      ['media_url','string','Public or signed URL.', true],
      ['window','number','Analysis window in seconds. Default 0.25.'],
      ['threshold','number','Energy threshold from 0-1 for returned peaks/segments.'],
      ['quiet_threshold','number','Optional low-energy threshold from 0-1. Default 0.12.'],
    ],
    responseFields: [
      ['segments','TimelineSegment[]','Merged high-energy ranges.'],
      ['peaks','object[]','Individual high-energy windows with timestamps and scores.'],
      ['quiet_segments','TimelineSegment[]','Low-energy ranges useful for trimming or cut points.'],
    ],
    requestExample: { 'media_url':'https://cdn.../ep-42.mp4', 'window': 0.25, 'threshold': 0.75, 'quiet_threshold': 0.12 },
    responseExample: {
      'segments':[{ 'kind':'energy_peak','start':312.5,'end':318.0,'score':0.92 }],
      'quiet_segments':[{ 'kind':'low_energy','start':644.0,'end':651.5,'score':0.89 }],
      'price_usd': 0.039,
    },
    related: ['audio/detect-laughter','audio/detect-music','timeline/merge'],
  },

  'audio/detect-speakers': {
    group: 'audio', title: 'detect-speakers', path: '/v1/audio/detect-speakers', method: 'POST',
    short: 'Anonymous speaker-turn candidates: who spoke when, without identity recognition.',
    when: 'Use for meeting memory, podcast transcripts with speaker-style tags, or call analytics where anonymous labels are enough.',
    price: '$0.045 / audio min',
    params: [
      ['media_url','string','Public or signed URL.', true],
      ['method','"signal"|"activity"','Detection method. Default "signal".'],
      ['num_speakers','integer','Optional known speaker count.'],
      ['min_speakers','integer','Minimum speakers when count is unknown. Default 1.'],
      ['max_speakers','integer','Maximum speakers when count is unknown. Default 8.'],
      ['speech_threshold','number','Speech activity threshold from 0-1. Default 0.08.'],
    ],
    responseFields: [
      ['segments','TimelineSegment[]','Anonymous speaker-turn segments.'],
      ['speakers','object[]','Anonymous labels such as Speaker 1 and Speaker 2 with rough speaking share.'],
      ['identity_recognition','false','This endpoint does not identify people.'],
      ['model_status','object','Reports whether an ML diarization model was used.'],
    ],
    requestExample: { 'media_url':'https://cdn.../meeting.mp4', 'method': 'signal', 'min_speakers': 1, 'max_speakers': 4, 'speech_threshold': 0.08 },
    responseExample: {
      'segments':[{ 'kind':'speaker_turn','start':3.4,'end':14.8,'metadata':{ 'speaker_label':'Speaker 1' } }],
      'speakers':[{ 'label':'Speaker 1', 'share':0.62 }, { 'label':'Speaker 2', 'share':0.38 }],
      'identity_recognition': false,
      'method':'signal_turn_candidates_acoustic_clustering',
      'price_usd': 0.87,
    },
    related: ['audio/topic-segments','audio/isolate-speech','timeline/merge'],
  },

  'audio/detect-music': {
    group: 'audio', title: 'detect-music', path: '/v1/audio/detect-music', method: 'POST',
    short: 'Detect likely music sections for cleaner editing and AI processing.',
    when: 'Use to skip music in podcast intros, find scored sections in trailers, or align cuts to likely music drops. No song identity or copyright safety claim is made.',
    price: '$0.025 / audio min',
    params: [
      ['media_url','string','Public or signed URL.', true],
      ['method','"signal"|"heuristic"','Detection method. Default "signal".'],
      ['threshold','number','Music likelihood threshold from 0-1. Default 0.62.'],
      ['min_duration','number','Minimum likely music section in seconds (default 2.0).'],
      ['window','number','Analysis window in seconds. Default 1.0.'],
    ],
    responseFields: [
      ['segments','TimelineSegment[]','Likely music regions. No song identity or copyright claim is made.'],
      ['music','TimelineSegment[]','Alias of segments for convenience.'],
      ['model_status','object','Reports whether a music classifier model was used.'],
    ],
    requestExample: { 'media_url':'https://cdn.../ep-42.mp4', 'method': 'signal', 'threshold': 0.62, 'min_duration': 2 },
    responseExample: {
      'segments':[{ 'kind':'music_candidate','start':396.5,'end':409.0,'confidence':0.72 }],
      'music':[{ 'kind':'music_candidate','start':396.5,'end':409.0,'confidence':0.72 }],
      'price_usd': 0.13,
    },
    related: ['audio/isolate-speech','audio/detect-energy','timeline/suggest-ranges'],
  },

  'audio/isolate-speech': {
    group: 'audio', title: 'isolate-speech', path: '/v1/audio/isolate-speech', method: 'POST',
    short: 'Create speech-focused media by reducing non-speech ranges and applying cleanup filters.',
    when: 'Use as preprocessing for transcription, podcast cleanup, or speech-heavy AI workflows. This is not true source separation.',
    price: '$0.045 / output min',
    params: [
      ['media_url','string','Public or signed URL.', true],
      ['method','"filter"','Speech focus method. Current live method is "filter".'],
      ['format','"mp3"|"wav"|"m4a"','Output audio format. Default "mp3".'],
      ['strength','"light"|"medium"|"strong"','Cleanup strength. Default "medium".'],
      ['start','number|string','Optional start timestamp.'],
      ['end','number|string','Optional end timestamp. Blank means end of media.'],
      ['denoise','boolean','Apply FFmpeg denoise. Default true.'],
      ['normalize','boolean','Apply dynamic audio normalization. Default true.'],
    ],
    responseFields: [
      ['url','string','Signed URL to the speech-focused media.'],
      ['duration','number','Output duration in seconds.'],
      ['output_file','object','Stored output metadata.'],
      ['filters','string[]','FFmpeg cleanup filters applied.'],
      ['source_separation','false','This is cleanup/filtering, not true speech source separation.'],
    ],
    requestExample: { 'media_url':'https://cdn.../ep-42.mp4', 'method': 'filter', 'format':'mp3', 'strength':'medium', 'denoise':true, 'normalize':true },
    responseExample: { 'url':'https://cdn.../clean/speech-focused.mp3', 'duration': 2892, 'format':'mp3', 'source_separation': false, 'price_usd': 2.17 },
    related: ['audio/detect-music','audio/detect-speakers','video/clip-window'],
  },

  'audio/suggest-cut-points': {
    group: 'audio', title: 'suggest-cut-points', path: '/v1/audio/suggest-cut-points', method: 'POST',
    short: 'Suggest natural edit boundaries — silence + speaker changes + topic shifts.',
    when: 'Use to render clips that start/end at clean, natural points — never mid-syllable.',
    short: 'Suggest clean edit boundaries from low-energy valleys.',
    when: 'Use to render clips that start/end at quieter, cleaner points instead of cutting through active speech.',
    price: '$0.02 / audio min',
    params: [
      ['media_url','string','Public or signed URL.', true],
      ['around','number[]','Optional list of timestamps to search near.'],
      ['quiet_threshold','number','Low-energy threshold from 0-1. Default 0.18.'],
      ['search_window','number','Seconds to search on each side of each around timestamp. Default 15.'],
      ['min_gap','number','Minimum seconds between returned cuts. Default 5.'],
      ['max_cuts','integer','Maximum cuts to return. Default 50.'],
    ],
    responseFields: [
      ['cuts','{t,score,confidence,reason}[]','Suggested cut points.'],
      ['segments','TimelineSegment[]','Cut points as timeline-compatible objects.'],
    ],
    requestExample: { 'media_url':'https://cdn.../ep-42.mp4', 'around':[862.4], 'quiet_threshold':0.18 },
    responseExample: {
      'cuts':[{ 't': 850.1, 'score':0.92, 'confidence':0.86, 'reason':'quiet_valley_near_target' }],
      'price_usd': 0.067,
    },
    related: ['audio/detect-silence','video/clip-near','timeline/suggest-ranges'],
  },

  'audio/find-words': {
    group: 'audio', title: 'find-words', path: '/v1/audio/find-words', method: 'POST',
    short: 'Find words or phrases in a transcript and return clip-ready timestamps.',
    when: 'Use after transcription when an AI agent or user needs every moment where a word, phrase, name, product, or topic was said.',
    price: '$0.01 / request',
    params: [
      ['words','Word[]','Timestamped word list from audio/transcribe.', true],
      ['queries','string[]','Words or phrases to find.', true],
      ['match','"phrase"|"word"|"contains"','Match mode. Default "phrase".'],
      ['before','number','Seconds of context before each match for clip_start. Default 0.'],
      ['after','number','Seconds of context after each match for clip_end. Default 0.'],
    ],
    responseFields: [
      ['matches','TimelineSegment[]','Word or phrase matches with start/end and clip padding metadata.'],
    ],
    requestExample: { 'words': [{ 'word':'Batman', 'start':38.14, 'end':38.52 }], 'queries':['Batman','detective comics'], 'before':2, 'after':3 },
    responseExample: {
      'matches':[
        { 'kind':'word_match','start':38.14,'end':38.52,'metadata':{ 'query':'Batman','clip_start':36.14,'clip_end':41.52 } },
      ],
      'price_usd': 0.010,
    },
    related: ['audio/topic-segments','video/clip-window','timeline/merge'],
  },

  'audio/topic-segments': {
    group: 'audio', title: 'topic-segments', path: '/v1/audio/topic-segments', method: 'POST',
    short: 'Split transcript text into timestamped topic sections.',
    when: 'Use after transcription to index podcasts, lectures, and meetings into topic-aware sections for search, clipping, and agent memory.',
    price: '$0.035 / audio min',
    params: [
      ['transcript','TranscriptSegment[]','Timestamped transcript segments from audio/transcribe.', true],
      ['target_duration','number','Preferred section duration in seconds. Default 30.'],
      ['min_duration','number','Minimum section duration before splitting. Default 8.'],
      ['max_duration','number','Maximum section duration. Default 60.'],
      ['method','"topic"|"duration"','Segmentation method. Default "topic".'],
    ],
    responseFields: [
      ['segments','TimelineSegment[]','Topic-aware timestamped sections.'],
      ['segments[].metadata.title','string','Human-readable topic title from key terms.'],
      ['segments[].metadata.keywords','string[]','Key terms for the segment.'],
    ],
    requestExample: { 'transcript': [{ 'start':3.06, 'end':9.66, 'text':'Sitting here with Greg Ruka...' }], 'target_duration': 30, 'min_duration': 8, 'max_duration': 60 },
    responseExample: {
      'segments':[
        { 'kind':'topic_segment','start':3.06,'end':18.0,'metadata':{ 'title':'Many / Comics / Sitting / Greg', 'keywords':['many','comics','greg'] } },
        { 'kind':'topic_segment','start':18.0,'end':27.26,'metadata':{ 'title':'Differences / Between / Doing / Indie', 'keywords':['indie','comic'] } },
      ],
      'price_usd': 1.69,
    },
    related: ['audio/find-words','timeline/merge'],
  },

  'audio/semantic-chunks': {
    group: 'audio', title: 'semantic-chunks', path: '/v1/audio/semantic-chunks', method: 'POST',
    short: 'Split audio by topic and speaker — not by fixed seconds.',
    when: 'Use to index podcasts, lectures, and meetings into RAG with chunks that respect topic boundaries.',
    price: '$0.035 / audio min',
    params: [
      ['media_url','string','Public or signed URL.', true],
      ['target_chunk_seconds','number','Soft target chunk length in seconds (default 90).'],
    ],
    responseFields: [
      ['chunks','{start,end,topic,speakers}[]','Topic-aware chunks.'],
    ],
    requestExample: { 'media_url':'https://cdn.../lecture.mp4', 'target_chunk_seconds': 60 },
    responseExample: {
      'chunks':[
        { 'start':0,'end':62.1,'topic':'intro','speakers':['A'] },
        { 'start':62.1,'end':148.4,'topic':'leibniz_rule','speakers':['A'] },
      ],
      'price_usd': 1.69,
    },
    related: ['audio/detect-speakers','video/text-frames','timeline/merge'],
  },

  'timeline/merge': {
    group: 'timeline', title: 'merge', path: '/v1/timeline/merge', method: 'POST',
    short: 'Combine results from many endpoints into a single shared timeline.',
    when: 'Use as the canonical "what happened in this media" object — feed it to your model, store it, query it.',
    price: '$0.002 / request',
    params: [
      ['media_seconds','number','Duration of the source media in seconds.', true],
      ['signals','Signal[]','Array of signal objects from any other endpoint.', true],
    ],
    responseFields: [
      ['timeline','Event[]','Unified, sorted event list.'],
      ['by_kind','{[kind]: Event[]}','Events grouped by signal kind.'],
    ],
    requestExample: {
      'media_seconds': 2892,
      'signals':[
        { 'kind':'laughter', 'events':[{ 't':862.4,'dur':1.8 }] },
        { 'kind':'silence',  'events':[{ 't':859.9,'dur':0.4 }] },
      ],
    },
    responseExample: {
      'timeline':[
        { 'kind':'silence',  't':859.9,'dur':0.4 },
        { 'kind':'laughter', 't':862.4,'dur':1.8 },
      ],
      'price_usd': 0.002,
    },
    related: ['timeline/find-nearest','timeline/suggest-ranges','audio/detect-laughter'],
  },

  'timeline/find-nearest': {
    group: 'timeline', title: 'find-nearest', path: '/v1/timeline/find-nearest', method: 'POST',
    short: 'Given a timestamp and a kind, return the nearest event of that kind.',
    when: 'Use when chaining endpoints — e.g. "find the silence right before this laugh".',
    price: '$0.002 / request',
    params: [
      ['timeline','Event[]','Timeline produced by /timeline/merge.', true],
      ['t','number','Reference timestamp in seconds.', true],
      ['kind','string','Event kind to search for.', true],
      ['direction','"before"|"after"|"any"','Search direction. Default "any".'],
    ],
    responseFields: [
      ['event','Event','The nearest event, or null.'],
      ['delta','number','Seconds from t to the event.'],
    ],
    requestExample: {
      'timeline':[
        { 'kind':'silence',  't': 859.9, 'dur': 0.4 },
        { 'kind':'laughter', 't': 862.4, 'dur': 1.8 },
      ],
      't': 862.4, 'kind': 'silence', 'direction': 'before',
    },
    responseExample: { 'event': { 'kind':'silence','t':859.9,'dur':0.4 }, 'delta': -2.5, 'price_usd': 0.002 },
    related: ['timeline/merge','timeline/suggest-ranges','audio/detect-silence'],
  },

  'timeline/suggest-ranges': {
    group: 'timeline', title: 'suggest-ranges', path: '/v1/timeline/suggest-ranges', method: 'POST',
    short: 'Suggest start/end ranges from many signals — your one-call clip recommender.',
    when: 'Use to convert a merged timeline into render-ready clip ranges. Common chain endpoint for podcast clip engines.',
    price: '$0.005 / request',
    params: [
      ['timeline','Event[]','Merged timeline.', true],
      ['anchors','string[]','Event kinds that mark interesting moments (e.g. ["laughter","energy_peak"]).', true],
      ['boundary_kinds','string[]','Event kinds used as boundary candidates (default ["silence","speaker_change"]).'],
      ['min_clip','number','Min clip length in seconds (default 8).'],
      ['max_clip','number','Max clip length in seconds (default 60).'],
    ],
    responseFields: [
      ['ranges','{start,end,reason}[]','Suggested clip ranges.'],
    ],
    requestExample: {
      'timeline':[
        { 'kind':'silence',     'start': 840.0, 'end': 850.1, 'confidence': 0.91 },
        { 'kind':'laughter',    'start': 862.4, 'end': 864.2, 'confidence': 0.94 },
        { 'kind':'energy_peak', 'start': 866.0, 'end': 875.5, 'confidence': 0.88 },
        { 'kind':'silence',     'start': 882.6, 'end': 886.0, 'confidence': 0.89 },
      ],
      'anchors':['laughter','energy_peak'],
      'min_clip': 12, 'max_clip': 45,
    },
    responseExample: {
      'ranges':[
        { 'start':850.1,'end':882.6,'reason':'laughter_anchor' },
        { 'start':1402.4,'end':1438.0,'reason':'energy_peak_anchor' },
      ],
      'price_usd': 0.005,
    },
    related: ['timeline/merge','video/clip-window','audio/detect-laughter'],
  },
};

// Endpoint groups — first-class metadata.
const GROUPS = {
  video: {
    id: 'video',
    name: 'Video',
    pathPrefix: '/v1/video/*',
    desc: 'Frame extraction, cuts, thumbnails, clipping.',
    blurb: 'Frames, cuts, thumbnails, clipping.',
    color: 'oklch(0.62 0.18 250)',
  },
  audio: {
    id: 'audio',
    name: 'Audio',
    pathPrefix: '/v1/audio/*',
    desc: 'Speech, silence, speakers, laughter, music, energy.',
    blurb: 'Speech, silence, speakers, laughter, music.',
    color: 'oklch(0.70 0.16 52)',
  },
  timeline: {
    id: 'timeline',
    name: 'Timeline',
    pathPrefix: '/v1/timeline/*',
    desc: 'Combine video + audio signals into one media timeline.',
    blurb: 'Combine signals into a shared timeline.',
    color: 'oklch(0.66 0.13 155)',
  },
};

// ---------------------------------------------------------------------------
// PRICING - BETA RATES
//
// These are public usage-only beta rates used by the website, docs,
// dashboard, Moment Lab estimator, and backend billing estimates.
// Revisit after production benchmarks, ML/GPU upgrades, and real customer
// usage patterns. Keep this data-driven so pricing changes stay centralized.
//
// These values drive the Moment Lab estimator, pricing page, docs labels,
// dashboard usage summaries, and backend estimates.
//
// To update pricing:
//   1. Edit PRICE_USD_PER_MIN below (numeric price per unit).
//   2. Edit PRICING_UNITS (further down) only if an endpoint's unit changes
//      (video_min | audio_min | request | output_min | clip_min).
// Every page reads through priceFor() / pricingUnit() / priceLabel() —
// no other file needs to change.
// ---------------------------------------------------------------------------
const PRICE_USD_PER_MIN = {
  'video/best-frames':        0.035,
  'video/best-frame-near':    0.015,
  'video/text-frames':        0.060,
  'video/detect-cuts':        0.012,
  'video/dedupe-frames':      0.015,
  'video/thumbnail-score':    0.025,
  'video/clip-window':        0.035,
  'video/clip-near':          0.035,
  'audio/detect-silence':     0.006,
  'audio/detect-laughter':    0.035,
  'audio/detect-energy':      0.008,
  'audio/detect-speakers':    0.045,
  'audio/detect-music':       0.025,
  'audio/isolate-speech':     0.045,
  'audio/find-words':         0.010,
  'audio/topic-segments':     0.035,
  'audio/suggest-cut-points': 0.020,
  'audio/semantic-chunks':    0.035,
  'timeline/merge':           0.002,
  'timeline/find-nearest':    0.002,
  'timeline/suggest-ranges':  0.005,
};

// Pricing unit per endpoint. Drives priceLabel() display.
// Allowed values: video_min | audio_min | request | output_min | clip_min
const PRICING_UNITS = {
  'video/best-frames':        'video_min',
  'video/best-frame-near':    'video_min',
  'video/text-frames':        'video_min',
  'video/detect-cuts':        'video_min',
  'video/dedupe-frames':      'video_min',
  'video/thumbnail-score':    'video_min',
  'video/clip-window':        'clip_min',
  'video/clip-near':          'clip_min',
  'audio/detect-silence':     'audio_min',
  'audio/detect-laughter':    'audio_min',
  'audio/detect-energy':      'audio_min',
  'audio/detect-speakers':    'audio_min',
  'audio/detect-music':       'audio_min',
  'audio/isolate-speech':     'output_min',
  'audio/find-words':         'request',
  'audio/topic-segments':     'audio_min',
  'audio/suggest-cut-points': 'audio_min',
  'audio/semantic-chunks':    'audio_min',
  'timeline/merge':           'request',
  'timeline/find-nearest':    'request',
  'timeline/suggest-ranges':  'request',
};

const VALID_PRICING_UNITS = new Set(['video_min','audio_min','request','output_min','clip_min']);

const UNIT_LABELS = {
  video_min:  'video min',
  audio_min:  'audio min',
  request:    'request',
  output_min: 'output min',
  clip_min:   'clip min',
};

// Helpers — every page should read these, not hardcode.
const endpointIds                 = () => Object.keys(ENDPOINTS);
const endpointIdsByGroup          = (g) => Object.keys(ENDPOINTS).filter(id => ENDPOINTS[id].group === g);
const endpointGroupCount          = (g) => endpointIdsByGroup(g).length;
const priceFor                    = (id) => ENDPOINTS[id]?.priceUsd ?? PRICE_USD_PER_MIN[id] ?? 0;
const pricingUnit                 = (id) => ENDPOINTS[id]?.pricingUnit || PRICING_UNITS[id] || 'request';
const priceUnit                   = (id) => `/ ${UNIT_LABELS[pricingUnit(id)] || pricingUnit(id)}`;
const rateDecimals                = (value) => {
  const n = Number(value || 0);
  return Math.abs((n * 100) - Math.round(n * 100)) > 1e-9 || n < 0.01 ? 3 : 2;
};
const priceLabel                  = (id) => {
  const p = priceFor(id);
  const decimals = rateDecimals(p);
  return `$${p.toFixed(decimals)} ${priceUnit(id)}`;
};
const docsHrefFor                 = (id) => `docs-${id.replace('/','-')}.html`;

// ---------------------------------------------------------------------------
// SEO metadata — single source of truth for every endpoint page <title>/<meta>.
// Endpoint HTML files set document.title from these at runtime via EndpointPage.
// ---------------------------------------------------------------------------
const SEO = {
  'video/best-frames':         { seoTitle: 'video/best-frames API — extract the best frames from a video | MomentIQ',         seoDescription: 'Return the clearest, most informative frames from a video, ranked. Request, response, and cURL/JS/Python examples for /v1/video/best-frames.' },
  'video/best-frame-near':     { seoTitle: 'video/best-frame-near API — best frame near a timestamp | MomentIQ',              seoDescription: 'Return the best still image near a target timestamp. Reference docs for /v1/video/best-frame-near with parameters, response schema, and code samples.' },
  'video/text-frames':         { seoTitle: 'video/text-frames API — frames containing slides, whiteboards, screens | MomentIQ', seoDescription: 'Find frames with readable text — slides, whiteboards, dashboards, terminals — with optional OCR. Reference docs for /v1/video/text-frames.' },
  'video/detect-cuts':         { seoTitle: 'video/detect-cuts API — shot boundaries and scene changes | MomentIQ',            seoDescription: 'Detect hard visual changes — shot boundaries, slide swaps, scene jumps. Reference docs for /v1/video/detect-cuts.' },
  'video/dedupe-frames':       { seoTitle: 'video/dedupe-frames API — remove repetitive frames | MomentIQ',                   seoDescription: 'Drop visually-repetitive frames from a list and keep one representative per cluster. Reference docs for /v1/video/dedupe-frames.' },
  'video/thumbnail-score':     { seoTitle: 'video/thumbnail-score API — rank frames for thumbnail use | MomentIQ',            seoDescription: 'Score frames for thumbnail suitability — face presence, contrast, focal interest. Reference docs for /v1/video/thumbnail-score.' },
  'video/clip-window':         { seoTitle: 'video/clip-window API — render a clip from start to end | MomentIQ',              seoDescription: 'Cut a clip from a start and end timestamp. Returns a playable URL. Reference docs for /v1/video/clip-window.' },
  'video/clip-near':           { seoTitle: 'video/clip-near API — render a clip centered on a moment | MomentIQ',             seoDescription: 'Cut a clip centered on a timestamp with smart boundary alignment to silence and cuts. Reference docs for /v1/video/clip-near.' },
  'audio/detect-silence':      { seoTitle: 'audio/detect-silence API — find silence regions in audio | MomentIQ',              seoDescription: 'Find silence regions, breath pauses, and natural cut points. Reference docs for /v1/audio/detect-silence with parameters and examples.' },
  'audio/detect-laughter':     { seoTitle: 'audio/detect-laughter API — detect laughter in podcasts and streams | MomentIQ',  seoDescription: 'Detect laughter moments with confidence scores. Perfect for podcast clipping. Reference docs for /v1/audio/detect-laughter.' },
  'audio/detect-energy':       { seoTitle: 'audio/detect-energy API — score audio energy over time | MomentIQ',                seoDescription: 'Score audio energy over time — find loud, intense, or quiet parts. Reference docs for /v1/audio/detect-energy.' },
  'audio/detect-speakers':     { seoTitle: 'audio/detect-speakers API — speaker diarization | MomentIQ',                       seoDescription: 'Speaker diarization — who spoke when, with optional overlap detection. Reference docs for /v1/audio/detect-speakers.' },
  'audio/detect-music':        { seoTitle: 'audio/detect-music API — detect music sections | MomentIQ',                        seoDescription: 'Detect music sections and speech-vs-music overlap. Reference docs for /v1/audio/detect-music.' },
  'audio/isolate-speech':      { seoTitle: 'audio/isolate-speech API - speech-focused media cleanup | MomentIQ',               seoDescription: 'Create speech-focused media by reducing non-speech ranges and applying cleanup filters. Reference docs for /v1/audio/isolate-speech.' },
  'audio/find-words':          { seoTitle: 'audio/find-words API - find spoken words with timestamps | MomentIQ',               seoDescription: 'Find words or phrases in timestamped transcripts and return clip-ready ranges. Reference docs for /v1/audio/find-words.' },
  'audio/topic-segments':      { seoTitle: 'audio/topic-segments API - transcript topic sections | MomentIQ',                   seoDescription: 'Split timestamped transcripts into topic-aware sections for search, clipping, and agent workflows. Reference docs for /v1/audio/topic-segments.' },
  'audio/suggest-cut-points':  { seoTitle: 'audio/suggest-cut-points API — natural edit boundaries | MomentIQ',                seoDescription: 'Suggest natural edit boundaries from silence + speaker changes + topic shifts. Reference docs for /v1/audio/suggest-cut-points.' },
  'audio/semantic-chunks':     { seoTitle: 'audio/semantic-chunks API — split audio by topic and speaker | MomentIQ',          seoDescription: 'Split audio into topic-aware chunks for RAG indexing. Reference docs for /v1/audio/semantic-chunks.' },
  'timeline/merge':            { seoTitle: 'timeline/merge API — combine signals into one media timeline | MomentIQ',          seoDescription: 'Combine outputs from any MomentIQ endpoint into a single shared timeline. Reference docs for /v1/timeline/merge.' },
  'timeline/find-nearest':     { seoTitle: 'timeline/find-nearest API — nearest event of a kind | MomentIQ',                   seoDescription: 'Given a timestamp and event kind, return the nearest event. Reference docs for /v1/timeline/find-nearest.' },
  'timeline/suggest-ranges':   { seoTitle: 'timeline/suggest-ranges API — recommend clip ranges | MomentIQ',                   seoDescription: 'Convert a merged timeline into render-ready clip ranges. Reference docs for /v1/timeline/suggest-ranges.' },
};

// ---------------------------------------------------------------------------
// Playground metadata — drives the shared <Playground> component.
//   playgroundEnabled    — show in selector
//   playgroundLabel      — short label for the selector / header
//   playgroundDescription — 1-line description shown above the controls
//   playgroundControls   — array of input descriptors:
//                            { key, type, label, default, hint?, options?, min?, max?, step? }
//                          types: 'url' | 'text' | 'number' | 'select' | 'toggle' | 'json'
//   defaultRequest       — initial request body (merged with current control values)
//   fakeResponseType     — 'frames' | 'timeline-segments' | 'clip' | 'signal'
// ---------------------------------------------------------------------------

const ENDPOINT_EXPLAINERS = {
  'video/best-frames': {
    does: ['Scores frames across a video and returns the clearest, most useful still images with timestamps and signed image URLs.', 'Optimizes for visual quality signals like sharpness, brightness, contrast, entropy, and informativeness.'],
    doesNot: ['Does not dedupe every near-identical frame by itself; use video/dedupe-frames when uniqueness is the goal.', 'Does not decide whether a frame is the best thumbnail for clicks; use video/thumbnail-score for that.'],
    goodFor: ['thumbnail candidates', 'visual summaries', 'RAG image indexing', 'finding clean representative frames'],
  },
  'video/best-frame-near': {
    does: ['Searches around one known timestamp and returns the best still frame in that local window.', 'Keeps the result anchored near the moment you care about instead of scanning the whole video.'],
    doesNot: ['Does not discover the interesting timestamp for you; pair it with audio or timeline detection first.', 'Does not return a full ranked gallery; use video/best-frames for that.'],
    goodFor: ['cover frames for known moments', 'quote thumbnails', 'chapter images', 'clip preview images'],
  },
  'video/text-frames': {
    does: ['Finds frames that appear to contain readable text such as slides, whiteboards, screens, dashboards, or terminals.', 'Can optionally include OCR text when requested.'],
    doesNot: ['Does not guarantee perfect OCR or full document reconstruction.', 'Does not treat tiny recurring watermarks or logos as useful text when filtering is enabled.'],
    goodFor: ['lecture notes', 'screen-recording indexing', 'slide extraction', 'visual RAG inputs'],
  },
  'video/detect-cuts': {
    does: ['Returns timestamps where the visual content changes sharply, such as shot boundaries, slide changes, or scene jumps.', 'Also returns convenience segments derived from those cut points.'],
    doesNot: ['Does not understand story, topic, or speaker meaning by itself.', 'Does not render clips; send chosen ranges to video/clip-window.'],
    goodFor: ['chaptering videos', 'scene-aware previews', 'slide-change detection', 'timeline building'],
  },
  'video/dedupe-frames': {
    does: ['Takes a list of frame objects and removes near-duplicates, keeping one representative frame per visual cluster.', 'Works best after another endpoint has already produced candidate frames.'],
    doesNot: ['Does not split a video into frames on its own in the normal chain; give it frames from best-frames or text-frames.', 'Does not rank aesthetic quality; it focuses on similarity and redundancy.'],
    goodFor: ['clean frame galleries', 'removing duplicate slide frames', 'compact RAG image sets', 'thumbnail candidate cleanup'],
  },
  'video/thumbnail-score': {
    does: ['Scores candidate frames for thumbnail usefulness and returns reasons such as contrast, focal interest, or face-like composition.', 'Ranks frames you already selected or extracted.'],
    doesNot: ['Does not guarantee higher click-through rate.', 'Does not generate new thumbnail artwork, text overlays, or edited images.'],
    goodFor: ['choosing final thumbnails', 'ranking creator frame options', 'A/B thumbnail candidate selection'],
  },
  'video/clip-window': {
    does: ['Cuts the source media from an exact start timestamp to an exact end timestamp and returns a playable output URL.', 'Uses the requested output format when supported.'],
    doesNot: ['Does not choose the best start or end for you.', 'Does not remove filler, detect highlights, or alter the clip content unless another endpoint provides those decisions.'],
    goodFor: ['rendering known timestamp ranges', 'turning timeline ranges into files', 'downloadable clips', 'agent-created video snippets'],
  },
  'video/clip-near': {
    does: ['Creates a clip around a target timestamp, optionally snapping the boundaries to nearby silence or cut points.', 'Returns the final start, end, and playable output URL.'],
    doesNot: ['Does not discover the important moment by itself.', 'Does not guarantee perfect editorial timing when the nearby audio or visual boundaries are messy.'],
    goodFor: ['clips around detected laughs', 'clips around quotes', 'quick social snippets', 'moment-centered editing'],
  },
  'audio/detect-silence': {
    does: ['Finds silence or no-speech regions in audio and returns timestamped timeline segments.', 'Can use VAD-style speech activity or a dB threshold depending on settings.'],
    doesNot: ['Does not know whether a pause is editorially good by itself.', 'Does not remove silence from the media; use clip endpoints or your own editor to cut.'],
    goodFor: ['natural cut points', 'dead-air trimming', 'ending clips after a laugh or quote', 'timeline alignment'],
  },
  'audio/detect-laughter': {
    does: ['Returns likely laughter candidate segments with confidence and scores.', 'Uses audio patterns and speech filtering to reduce energetic-speech false positives.'],
    doesNot: ['Does not guarantee every result is laughter.', 'Does not understand the joke or decide whether the moment is funny enough to publish.'],
    goodFor: ['podcast clip discovery', 'stream highlight candidates', 'finding audience reactions', 'starting clip-near workflows'],
  },
  'audio/detect-energy': {
    does: ['Scores loudness and intensity over time, returning high-energy peaks and low-energy ranges.', 'Produces timestamped segments useful as a signal in larger workflows.'],
    doesNot: ['Does not detect emotion or sentiment directly.', 'Does not know whether high energy is laughter, music, yelling, applause, or noise without other endpoints.'],
    goodFor: ['energy heatmaps', 'highlight candidates', 'quiet-range trimming', 'finding intense sections'],
  },
  'audio/detect-speakers': {
    does: ['Returns anonymous speaker-turn segments like Speaker 1 and Speaker 2 with rough speaking share.', 'Helps answer who spoke when without naming people.'],
    doesNot: ['Does not identify real people or verify identity.', 'Does not replace legal-grade diarization or perfect meeting transcripts.'],
    goodFor: ['meeting memory', 'podcast turn-taking', 'speaker-aware timelines', 'conversation analytics'],
  },
  'audio/detect-music': {
    does: ['Finds likely music sections and returns timestamped music-candidate segments.', 'Helps agents avoid or reduce music-heavy ranges before transcription or clipping.'],
    doesNot: ['Does not identify songs, artists, rights owners, or copyright status.', 'Does not guarantee that output is safe for publishing or monetization.'],
    goodFor: ['skipping intros and outros', 'music-aware clipping', 'cleaner speech processing', 'media structure detection'],
  },
  'audio/isolate-speech': {
    does: ['Creates a speech-focused audio output by applying cleanup filters and reducing non-speech emphasis.', 'Returns a new audio URL plus metadata about the filters used.'],
    doesNot: ['Does not perform true source separation in the current live version.', 'Does not perfectly remove music, background voices, or noise from every file.'],
    goodFor: ['transcription preprocessing', 'podcast cleanup', 'speech-first AI workflows', 'rough audio enhancement'],
  },
  'audio/suggest-cut-points': {
    does: ['Suggests timestamp cut points based on quiet valleys and optional target timestamps.', 'Returns cut candidates and timeline-compatible segments.'],
    doesNot: ['Does not render clips or decide final creative edits automatically.', 'Does not understand the narrative meaning of a scene without other signals.'],
    goodFor: ['clean edit boundaries', 'clip start/end suggestions', 'silence-aware cuts', 'agent editing plans'],
  },
  'audio/find-words': {
    does: ['Searches timestamped word data for words or phrases and returns matching ranges.', 'Can add before/after padding so matches are ready for clipping.'],
    doesNot: ['Does not transcribe audio by itself; it needs word timestamps from a transcript source.', 'Does not prove speaker identity or quote accuracy beyond the provided transcript data.'],
    goodFor: ['finding exact quotes', 'word-triggered clips', 'agent prompt workflows', 'searching transcripts'],
  },
  'audio/topic-segments': {
    does: ['Splits long media into topic-style timestamp sections using audio structure and low-energy boundaries.', 'Returns timeline-compatible section ranges for downstream search, summaries, or clips.'],
    doesNot: ['Does not currently perform deep semantic understanding unless a transcript/semantic model is configured.', 'Does not write summaries or titles with an LLM by itself.'],
    goodFor: ['podcast chapters', 'long-video sectioning', 'RAG chunk ranges', 'agent planning before summarization'],
  },
  'audio/semantic-chunks': {
    does: ['Provides compatibility for topic-style media chunks and returns the same kind of section ranges as topic-segments.', 'Keeps older integrations working while the clearer topic-segments name is preferred.'],
    doesNot: ['Does not guarantee true semantic embeddings in the current live configuration.', 'Does not replace a transcript-based chunker when exact language meaning is required.'],
    goodFor: ['RAG-style media ranges', 'legacy integrations', 'topic-like audio chunks', 'long-content indexing'],
  },
  'timeline/merge': {
    does: ['Combines outputs from multiple MomentIQ endpoints into one sorted, shared media timeline.', 'Normalizes timestamps, kinds, sources, scores, and metadata into compatible timeline segments.'],
    doesNot: ['Does not merge video files or create a montage.', 'Does not analyze media; it works on JSON signals you already have.'],
    goodFor: ['combining audio and video signals', 'agent decision layers', 'single timeline objects', 'multi-endpoint workflows'],
  },
  'timeline/find-nearest': {
    does: ['Finds the closest event of a requested kind before, after, or around a timestamp.', 'Works on an existing timeline object rather than raw media.'],
    doesNot: ['Does not detect new events from audio or video.', 'Does not render or edit clips directly.'],
    goodFor: ['ending clips at the next silence', 'snapping moments to nearby cuts', 'agent boundary decisions', 'timeline queries'],
  },
  'timeline/suggest-ranges': {
    does: ['Turns timeline events into suggested clip ranges using anchors such as laughter, energy peaks, cuts, or silence.', 'Returns ranges that can be passed into video/clip-window.'],
    doesNot: ['Does not render the clips itself.', 'Does not guarantee the final range is publish-worthy without user or agent review.'],
    goodFor: ['clip recommendation', 'highlight workflows', 'agent-created edit lists', 'batch range planning'],
  },
};

const _u = 'demo/backroom-comics-greg-rucka-sample.mp4';
const _p = 'demo/backroom-comics-greg-rucka-sample.mp4';
const _m = 'demo/backroom-comics-greg-rucka-sample.mp4';

const PLAYGROUND = {
  'video/best-frames': {
    playgroundEnabled: true,
    playgroundLabel: 'Pick the best frames',
    playgroundDescription: 'Return the clearest, most informative frames in a video — ranked.',
    playgroundControls: [
      { key:'media_url',  type:'url',    label:'media_url',  default:_u },
      { key:'max_frames', type:'number', label:'max_frames', default:8, min:1, max:64, step:1 },
      { key:'min_gap',    type:'number', label:'min_gap',    default:6, min:0, max:60, step:1, hint:'seconds' },
      { key:'quality',    type:'select', label:'quality',    default:'std', options:['low','std','high'] },
    ],
    defaultRequest: { media_url:_u, max_frames:8, min_gap:6, quality:'std' },
    fakeResponseType: 'frames',
  },
  'video/best-frame-near': {
    playgroundEnabled: true,
    playgroundLabel: 'Best frame near a timestamp',
    playgroundDescription: 'One clean cover frame near a moment you already know about.',
    playgroundControls: [
      { key:'media_url', type:'url',    label:'media_url', default:_p },
      { key:'t',         type:'number', label:'t',         default:862.4, min:0, step:0.1, hint:'seconds' },
      { key:'window',    type:'number', label:'window',    default:3, min:0.5, max:30, step:0.5 },
    ],
    defaultRequest: { media_url:_p, t:862.4, window:3 },
    fakeResponseType: 'frames',
  },
  'video/text-frames': {
    playgroundEnabled: true,
    playgroundLabel: 'Slide & whiteboard frames',
    playgroundDescription: 'Find frames containing readable text — for RAG, lecture notes, demo recaps.',
    playgroundControls: [
      { key:'media_url', type:'url',    label:'media_url', default:_u },
      { key:'ocr',       type:'toggle', label:'ocr',       default:true,  hint:'include OCR text' },
      { key:'dedupe',    type:'toggle', label:'dedupe',    default:true,  hint:'drop near-duplicates' },
      { key:'min_text_density', type:'number', label:'min_text_density', default:0.05, min:0, max:1, step:0.01 },
    ],
    defaultRequest: { media_url:_u, ocr:true, dedupe:true },
    fakeResponseType: 'frames',
  },
  'video/detect-cuts': {
    playgroundEnabled: true,
    playgroundLabel: 'Shot boundaries',
    playgroundDescription: 'Detect hard visual changes — shot boundaries, slide swaps, scene jumps.',
    playgroundControls: [
      { key:'media_url',   type:'url',    label:'media_url',   default:_p },
      { key:'sensitivity', type:'number', label:'sensitivity', default:0.6, min:0, max:1, step:0.05 },
      { key:'min_segment', type:'number', label:'min_segment', default:0.4, min:0, max:30, step:0.1, hint:'seconds' },
    ],
    defaultRequest: { media_url:_p, sensitivity:0.6 },
    fakeResponseType: 'timeline-segments',
  },
  'video/dedupe-frames': {
    playgroundEnabled: true,
    playgroundLabel: 'Dedupe frame list',
    playgroundDescription: 'Remove visually-repetitive frames — keep one representative per cluster.',
    playgroundControls: [
      { key:'frames',    type:'json',   label:'frames',    default:[{t:12.4},{t:12.6},{t:88.0}], hint:'Frame[]' },
      { key:'threshold', type:'number', label:'threshold', default:0.92, min:0.5, max:1, step:0.01 },
    ],
    defaultRequest: { frames:[{t:12.4},{t:12.6},{t:88.0}], threshold:0.92 },
    fakeResponseType: 'frames',
  },
  'video/thumbnail-score': {
    playgroundEnabled: true,
    playgroundLabel: 'Score frames for thumbnails',
    playgroundDescription: 'Rank a frame set by face presence, expression, contrast, focal interest.',
    playgroundControls: [
      { key:'frames', type:'json',   label:'frames', default:[{t:312},{t:901}], hint:'Frame[]' },
      { key:'target', type:'select', label:'target', default:'clip', options:['clip','video','chapter'] },
    ],
    defaultRequest: { frames:[{t:312},{t:901}], target:'clip' },
    fakeResponseType: 'frames',
  },
  'video/clip-window': {
    playgroundEnabled: true,
    playgroundLabel: 'Render a clip',
    playgroundDescription: 'Cut a clip from start to end. Returns a playable URL.',
    playgroundControls: [
      { key:'media_url',  type:'url',    label:'media_url',  default:_p },
      { key:'start',      type:'number', label:'start',      default:0, min:0, step:1, hint:'seconds' },
      { key:'end',        type:'number', label:'end',        default:2, min:0, step:1, hint:'seconds' },
      { key:'format',    type:'select', label:'format',    default:'mp4', options:['mp4','mov','mp3'] },
    ],
    defaultRequest: { media_url:_p, start:0, end:2, format:'mp4' },
    fakeResponseType: 'clip',
  },
  'video/clip-near': {
    playgroundEnabled: true,
    playgroundLabel: 'Smart-boundary clip',
    playgroundDescription: 'Cut a clip centered on a moment. Boundary snapping will use silence + cuts once that workflow is connected.',
    playgroundControls: [
      { key:'media_url',       type:'url',    label:'media_url',       default:_p },
      { key:'t',               type:'number', label:'t',               default:862.4, min:0, step:0.1 },
      { key:'target_duration', type:'number', label:'target_duration', default:30, min:5, max:600, step:1 },
      { key:'snap',            type:'toggle', label:'snap',            default:true, hint:'snap to silence/cuts' },
    ],
    defaultRequest: { media_url:_p, t:862.4, target_duration:30, snap:true },
    fakeResponseType: 'clip',
  },
  'audio/detect-silence': {
    playgroundEnabled: true,
    playgroundLabel: 'Find silences',
    playgroundDescription: 'Surface low-volume silence regions for natural edit boundaries. VAD speech detection is a later upgrade.',
    playgroundControls: [
      { key:'media_url',    type:'url',    label:'media_url',    default:_p },
      { key:'method',       type:'select', label:'method',       default:'vad', options:['vad','db'] },
      { key:'min_duration', type:'number', label:'min_duration', default:0.5, min:0.1, max:10, step:0.1, hint:'seconds' },
      { key:'speech_threshold', type:'number', label:'speech_threshold', default:0.5, min:0, max:1, step:0.05 },
      { key:'threshold_db', type:'number', label:'threshold_db', default:-35, min:-60, max:-10, step:1, hint:'dB' },
    ],
    defaultRequest: { media_url:_p, method:'db', min_duration:0.5, threshold_db:-35 },
    fakeResponseType: 'signal',
  },
  'audio/detect-laughter': {
    playgroundEnabled: true,
    playgroundLabel: 'Find laughs',
    playgroundDescription: 'Detect likely laughter candidate moments from rhythmic audio bursts. ML confirmation is a later upgrade.',
    playgroundControls: [
      { key:'media_url',   type:'url',    label:'media_url',   default:_p },
      { key:'threshold',   type:'number', label:'threshold',   default:0.15, min:0, max:1, step:0.01 },
      { key:'min_duration',type:'number', label:'min_duration',default:0.2,  min:0.1, max:5, step:0.1, hint:'seconds' },
      { key:'speech_filter', type:'toggle', label:'speech_filter', default:true },
    ],
    defaultRequest: { media_url:_p, threshold:0.15, min_duration:0.2, speech_filter:true },
    fakeResponseType: 'signal',
  },
  'audio/detect-energy': {
    playgroundEnabled: true,
    playgroundLabel: 'Score audio energy',
    playgroundDescription: 'Find loud high-energy ranges and low-energy quiet ranges for clipping or trimming.',
    playgroundControls: [
      { key:'media_url',  type:'url',    label:'media_url',  default:_p },
      { key:'window',     type:'number', label:'window',     default:0.25, min:0.1, max:5, step:0.05, hint:'seconds' },
      { key:'threshold',  type:'number', label:'threshold',  default:0.75, min:0, max:1, step:0.05 },
      { key:'quiet_threshold', type:'number', label:'quiet_threshold', default:0.12, min:0, max:1, step:0.01 },
    ],
    defaultRequest: { media_url:_p, window:0.25, threshold:0.75, quiet_threshold:0.12 },
    fakeResponseType: 'signal',
  },
  'audio/detect-speakers': {
    playgroundEnabled: true,
    playgroundLabel: 'Find speaker turns',
    playgroundDescription: 'Find anonymous speaker-turn candidates. This does not identify people.',
    playgroundControls: [
      { key:'media_url',     type:'url',    label:'media_url',     default:_m },
      { key:'method',        type:'select', label:'method',        default:'signal', options:['signal','activity'] },
      { key:'min_speakers',  type:'number', label:'min_speakers',  default:1, min:1, max:20, step:1 },
      { key:'max_speakers',  type:'number', label:'max_speakers',  default:4, min:1, max:20, step:1 },
      { key:'num_speakers',  type:'number', label:'num_speakers',  default:'', min:1, max:20, step:1 },
      { key:'speech_threshold', type:'number', label:'speech_threshold', default:0.08, min:0, max:1, step:0.01 },
    ],
    defaultRequest: { media_url:_m, method:'signal', min_speakers:1, max_speakers:4, speech_threshold:0.08 },
    fakeResponseType: 'signal',
  },
  'audio/detect-music': {
    playgroundEnabled: true,
    playgroundLabel: 'Find music sections',
    playgroundDescription: 'Detect likely music sections. This does not identify songs or guarantee copyright safety.',
    playgroundControls: [
      { key:'media_url',    type:'url',    label:'media_url',    default:_p },
      { key:'method',       type:'select', label:'method',       default:'signal', options:['signal','heuristic'] },
      { key:'threshold',    type:'number', label:'threshold',    default:0.62, min:0, max:1, step:0.01 },
      { key:'min_duration', type:'number', label:'min_duration', default:2, min:0.5, max:30, step:0.5 },
      { key:'window',       type:'number', label:'window',       default:1, min:0.25, max:5, step:0.25, hint:'seconds' },
    ],
    defaultRequest: { media_url:_p, method:'signal', threshold:0.62, min_duration:2, window:1 },
    fakeResponseType: 'signal',
  },
  'audio/isolate-speech': {
    playgroundEnabled: true,
    playgroundLabel: 'Speech-focused media',
    playgroundDescription: 'Create speech-focused cleaned audio with FFmpeg filters. Not true source separation.',
    playgroundControls: [
      { key:'media_url',          type:'url',    label:'media_url',          default:_p },
      { key:'method',             type:'select', label:'method',             default:'filter', options:['filter'] },
      { key:'format',             type:'select', label:'format',             default:'mp3', options:['mp3','wav','m4a'] },
      { key:'strength',           type:'select', label:'strength',           default:'medium', options:['light','medium','strong'] },
      { key:'start',              type:'time',   label:'start',              default:'00:00:00', hint:'blank = beginning' },
      { key:'end',                type:'time',   label:'end',                default:'', hint:'blank = end of media' },
      { key:'denoise',            type:'toggle', label:'denoise',            default:true },
      { key:'normalize',          type:'toggle', label:'normalize',          default:true },
    ],
    defaultRequest: { media_url:_p, method:'filter', format:'mp3', strength:'medium', start:'00:00:00', end:'', denoise:true, normalize:true },
    fakeResponseType: 'clip',
  },
  'audio/suggest-cut-points': {
    playgroundEnabled: true,
    playgroundLabel: 'Natural edit boundaries',
    playgroundDescription: 'Find low-energy cut points across the media or near target timestamps.',
    playgroundControls: [
      { key:'media_url', type:'url',  label:'media_url', default:_p },
      { key:'around',    type:'json', label:'around',    default:[862.4], hint:'number[] · seconds' },
    ],
    defaultRequest: { media_url:_p, around:[862.4] },
    fakeResponseType: 'signal',
  },
  'audio/find-words': {
    playgroundEnabled: true,
    playgroundLabel: 'Find spoken words',
    playgroundDescription: 'Search transcript word timestamps and return clip-ready matches.',
    playgroundControls: [
      { key:'words',   type:'json',   label:'words',   default:[{word:'Greg',start:4.18,end:4.42},{word:'Ruka',start:4.42,end:4.68},{word:'Batman',start:38.14,end:38.52}], hint:'Word[] from audio/transcribe' },
      { key:'queries', type:'json',   label:'queries', default:['Batman','Greg Ruka'], hint:'string[]' },
      { key:'match',   type:'select', label:'match',   default:'phrase', options:['phrase','word','contains'] },
      { key:'before',  type:'number', label:'before',  default:2, min:0, max:60, step:1, hint:'seconds' },
      { key:'after',   type:'number', label:'after',   default:3, min:0, max:60, step:1, hint:'seconds' },
    ],
    defaultRequest: { words:[{word:'Batman',start:38.14,end:38.52}], queries:['Batman'], match:'phrase', before:2, after:3 },
    fakeResponseType: 'signal',
  },
  'audio/topic-segments': {
    playgroundEnabled: true,
    playgroundLabel: 'Topic segments',
    playgroundDescription: 'Split timestamped transcript text into topic-aware sections.',
    playgroundControls: [
      { key:'transcript',       type:'json',   label:'transcript',       default:[{start:3.06,end:9.66,text:'Sitting here with Greg Ruka, creator of many comics.'},{start:18,end:27.26,text:'Differences between doing an indie comic and a major.'}], hint:'TranscriptSegment[]' },
      { key:'method',           type:'select', label:'method',           default:'topic', options:['topic','duration'] },
      { key:'target_duration',  type:'number', label:'target_duration',  default:30, min:5, max:600, step:5, hint:'seconds' },
      { key:'min_duration',     type:'number', label:'min_duration',     default:8, min:1, max:120, step:1, hint:'seconds' },
      { key:'max_duration',     type:'number', label:'max_duration',     default:60, min:5, max:900, step:5, hint:'seconds' },
    ],
    defaultRequest: { transcript:[{start:3.06,end:9.66,text:'Sitting here with Greg Ruka, creator of many comics.'}], method:'topic', target_duration:30, min_duration:8, max_duration:60 },
    fakeResponseType: 'timeline-segments',
  },
  'audio/semantic-chunks': {
    playgroundEnabled: false,
    playgroundLabel: 'Topic-aware chunks',
    playgroundDescription: 'Split audio by topic and speaker — for RAG indexing.',
    playgroundControls: [],
    defaultRequest: {},
    fakeResponseType: 'timeline-segments',
  },
  'timeline/merge': {
    playgroundEnabled: true,
    playgroundLabel: 'Merge signals into a timeline',
    playgroundDescription: 'Combine results from any endpoint into one canonical timeline.',
    playgroundControls: [
      { key:'media_seconds', type:'number', label:'media_seconds', default:2892, min:1, step:1 },
      { key:'signals',       type:'json',   label:'signals',       default:[{kind:'laughter',events:[{t:862.4,dur:1.8}]},{kind:'silence',events:[{t:859.9,dur:0.4}]}], hint:'Signal[]' },
    ],
    defaultRequest: { media_seconds:2892, signals:[{kind:'laughter',events:[{t:862.4,dur:1.8}]},{kind:'silence',events:[{t:859.9,dur:0.4}]}] },
    fakeResponseType: 'timeline-segments',
  },
  'timeline/find-nearest': {
    playgroundEnabled: true,
    playgroundLabel: 'Nearest event',
    playgroundDescription: 'Find the nearest event of a kind to a timestamp.',
    playgroundControls: [
      { key:'timeline',  type:'json',   label:'timeline',  default:[{kind:'silence',t:859.9,dur:0.4},{kind:'laughter',t:862.4,dur:1.8}], hint:'Event[]' },
      { key:'t',         type:'number', label:'t',         default:862.4, step:0.1 },
      { key:'kind',      type:'select', label:'kind',      default:'silence', options:['silence','laughter','speaker_change','energy_peak','cut','music'] },
      { key:'direction', type:'select', label:'direction', default:'before', options:['before','after','any'] },
    ],
    defaultRequest: { t:862.4, kind:'silence', direction:'before' },
    fakeResponseType: 'signal',
  },
  'timeline/suggest-ranges': {
    playgroundEnabled: true,
    playgroundLabel: 'Recommend clip ranges',
    playgroundDescription: 'Convert a merged timeline into render-ready clip ranges.',
    playgroundControls: [
      { key:'anchors',  type:'json',   label:'anchors',  default:['laughter','energy_peak'], hint:'string[]' },
      { key:'min_clip', type:'number', label:'min_clip', default:12, min:1, max:600, step:1 },
      { key:'max_clip', type:'number', label:'max_clip', default:45, min:1, max:600, step:1 },
    ],
    defaultRequest: { anchors:['laughter','energy_peak'], min_clip:12, max_clip:45 },
    fakeResponseType: 'timeline-segments',
  },
};

// ---------------------------------------------------------------------------
// Field normalization: convert any tuple-form params/responseFields entries
// (legacy ["name","type","desc",required?]) into the canonical object form.
//   params         → { name, type, description, required }
//   responseFields → { name, type, description }
// Renderers should read these objects. Tuple form is no longer authoritative.
// ---------------------------------------------------------------------------
const _normalizeParam = (f) => {
  if (!Array.isArray(f)) return f;
  const [name, type, description, required] = f;
  return { name, type, description, required: !!required };
};
const _normalizeResponseField = (f) => {
  if (!Array.isArray(f)) return f;
  const [name, type, description] = f;
  return { name, type, description };
};

// Apply SEO + Playground + pricingUnit onto each endpoint,
// normalize params/responseFields, and remove the legacy `price` string.
Object.keys(ENDPOINTS).forEach(id => {
  Object.assign(ENDPOINTS[id], SEO[id] || {}, ENDPOINT_EXPLAINERS[id] || {}, PLAYGROUND[id] || {});
  if (!ENDPOINTS[id].seoTitle)       ENDPOINTS[id].seoTitle = `${ENDPOINTS[id].path} — MomentIQ Docs`;
  if (!ENDPOINTS[id].seoDescription) ENDPOINTS[id].seoDescription = ENDPOINTS[id].short;

  // pricingUnit + priceUsd — single source of truth for pricing display.
  // (Beta values - see PRICE_USD_PER_MIN comment block above.)
  ENDPOINTS[id].pricingUnit = PRICING_UNITS[id] || (id.startsWith('timeline/') ? 'request' : `${id.split('/')[0]}_min`);
  ENDPOINTS[id].priceUsd    = PRICE_USD_PER_MIN[id] ?? 0;

  // Field normalization.
  if (Array.isArray(ENDPOINTS[id].params))         ENDPOINTS[id].params         = ENDPOINTS[id].params.map(_normalizeParam);
  if (Array.isArray(ENDPOINTS[id].responseFields)) ENDPOINTS[id].responseFields = ENDPOINTS[id].responseFields.map(_normalizeResponseField);

  delete ENDPOINTS[id].price;        // legacy duplicated string — pages now use priceLabel(id)
});

// ---------------------------------------------------------------------------
// Validation — runs at module load. Warnings only; never throws.
// Checks:
//   - every endpoint has a valid pricingUnit
//   - per-request endpoints are not displayed as "per minute"
//   - every required param appears in requestExample
//   - every playgroundControl key exists in params (or has uiOnly: true)
//   - every responseField is an object with name/type/description
//   - every param is an object with name/type/description/required
// ---------------------------------------------------------------------------
Object.assign(ENDPOINTS['audio/suggest-cut-points'], {
  playgroundEnabled: true,
  playgroundDescription: 'Find low-energy cut points across the media or near target timestamps.',
  params: [
    { name:'media_url', type:'string', description:'Public or signed URL.', required:true },
    { name:'around', type:'number[]', description:'Optional list of timestamps to search near.', required:false },
    { name:'quiet_threshold', type:'number', description:'Low-energy threshold from 0-1. Default 0.18.', required:false },
    { name:'search_window', type:'number', description:'Seconds to search on each side of each around timestamp. Default 15.', required:false },
    { name:'min_gap', type:'number', description:'Minimum seconds between returned cuts. Default 5.', required:false },
    { name:'max_cuts', type:'integer', description:'Maximum cuts to return. Default 50.', required:false },
  ],
  responseFields: [
    { name:'cuts', type:'{t,score,confidence,reason}[]', description:'Suggested cut points.' },
    { name:'segments', type:'TimelineSegment[]', description:'Cut points as timeline-compatible objects.' },
  ],
  requestExample: { media_url:'https://cdn.../ep-42.mp4', around:[862.4], quiet_threshold:0.18 },
  responseExample: {
    cuts:[{ t:850.1, score:0.92, confidence:0.86, reason:'quiet_valley_near_target' }],
    price_usd:0.10,
  },
  playgroundControls: [
    { key:'media_url', type:'url', label:'media_url', default:_p },
    { key:'around', type:'json', label:'around', default:[], hint:'optional number[] timestamps' },
    { key:'quiet_threshold', type:'number', label:'quiet_threshold', default:0.18, min:0, max:1, step:0.01 },
    { key:'search_window', type:'number', label:'search_window', default:15, min:1, max:120, step:1, hint:'seconds' },
    { key:'min_gap', type:'number', label:'min_gap', default:5, min:0.5, max:60, step:0.5, hint:'seconds' },
    { key:'max_cuts', type:'number', label:'max_cuts', default:50, min:1, max:200, step:1 },
  ],
  defaultRequest: { media_url:_p, around:[], quiet_threshold:0.18, search_window:15, min_gap:5, max_cuts:50 },
});

const topicSegmentControls = [
  { key:'media_url', type:'url', label:'media_url', default:_p },
  { key:'target_chunk_seconds', type:'number', label:'target_chunk_seconds', default:90, min:15, max:600, step:5, hint:'seconds' },
  { key:'min_chunk_seconds', type:'number', label:'min_chunk_seconds', default:30, min:5, max:300, step:5, hint:'seconds' },
  { key:'max_chunk_seconds', type:'number', label:'max_chunk_seconds', default:160, min:30, max:1200, step:10, hint:'seconds' },
  { key:'max_chunks', type:'number', label:'max_chunks', default:80, min:1, max:200, step:1 },
];
const topicSegmentDefaults = {
  media_url:_p,
  target_chunk_seconds:90,
  min_chunk_seconds:30,
  max_chunk_seconds:160,
  max_chunks:80,
};
const topicSegmentParams = [
  { name:'media_url', type:'string', description:'Public or signed URL.', required:true },
  { name:'target_chunk_seconds', type:'number', description:'Soft target section length in seconds. Default 90.', required:false },
  { name:'min_chunk_seconds', type:'number', description:'Minimum section duration. Default 30.', required:false },
  { name:'max_chunk_seconds', type:'number', description:'Maximum section duration. Default target * 1.75.', required:false },
  { name:'max_chunks', type:'integer', description:'Maximum chunks to return. Default 80.', required:false },
];
const topicSegmentResponseFields = [
  { name:'segments', type:'TimelineSegment[]', description:'Audio-structure sections snapped to low-energy boundaries.' },
  { name:'chunks', type:'TimelineSegment[]', description:'Alias of segments for compatibility.' },
  { name:'topics', type:'object[]', description:'Simple section labels and timestamp ranges.' },
  { name:'model_status', type:'object', description:'Reports whether transcript/semantic models were used.' },
];
Object.assign(ENDPOINTS['audio/topic-segments'], {
  short: 'Split media into timestamped topic-style sections from audio structure.',
  when: 'Use to create section ranges for podcasts, interviews, and long videos before deeper transcript semantic models are available.',
  params: topicSegmentParams,
  responseFields: topicSegmentResponseFields,
  requestExample: { media_url:'https://cdn.../lecture.mp4', target_chunk_seconds:90 },
  responseExample: {
    segments:[{ kind:'topic_segment', start:0, end:88.0, metadata:{ title:'Section 1', semantic_model:'not_configured' } }],
    method:'ffmpeg_energy_boundary_sections',
    price_usd:1.45,
  },
  playgroundEnabled: true,
  playgroundLabel: 'Topic sections',
  playgroundDescription: 'Split long media into section ranges using audio structure. Transcript semantics can come later.',
  playgroundControls: topicSegmentControls,
  defaultRequest: topicSegmentDefaults,
  fakeResponseType: 'timeline-segments',
});
Object.assign(ENDPOINTS['audio/semantic-chunks'], {
  short: 'Compatibility alias for topic-style media chunks.',
  when: 'Use topic-segments for the clearer name. This alias returns the same audio-structure chunks.',
  params: topicSegmentParams,
  responseFields: topicSegmentResponseFields,
  requestExample: { media_url:'https://cdn.../lecture.mp4', target_chunk_seconds:90 },
  responseExample: {
    chunks:[{ kind:'topic_segment', start:0, end:88.0, metadata:{ title:'Section 1', semantic_model:'not_configured' } }],
    method:'ffmpeg_energy_boundary_sections',
    price_usd:1.45,
  },
  playgroundEnabled: true,
  playgroundLabel: 'Topic chunks',
  playgroundDescription: 'Alias of Topic sections for RAG-style chunking.',
  playgroundControls: topicSegmentControls,
  defaultRequest: topicSegmentDefaults,
  fakeResponseType: 'timeline-segments',
});

const validateEndpoints = () => {
  const issues = [];
  Object.keys(ENDPOINTS).forEach(id => {
    const ep = ENDPOINTS[id];

    // pricingUnit
    if (!ep.pricingUnit)                       issues.push(`[${id}] missing pricingUnit`);
    else if (!VALID_PRICING_UNITS.has(ep.pricingUnit)) issues.push(`[${id}] invalid pricingUnit "${ep.pricingUnit}"`);

    // per-request must not render as per-minute
    if (ep.pricingUnit === 'request') {
      const label = priceLabel(id);
      if (/\bmin\b/.test(label))               issues.push(`[${id}] pricingUnit=request but priceLabel says "${label}"`);
    }

    ['does', 'doesNot', 'goodFor'].forEach(field => {
      if (!Array.isArray(ep[field]) || ep[field].length === 0) {
        issues.push(`[${id}] missing explainer field "${field}"`);
      }
    });

    // params object form
    (ep.params || []).forEach((p, i) => {
      if (!p || typeof p !== 'object' || Array.isArray(p)) {
        issues.push(`[${id}] params[${i}] is not an object`); return;
      }
      if (!p.name)                             issues.push(`[${id}] params[${i}] missing name`);
      if (!p.type)                             issues.push(`[${id}] params[${i}].${p.name||i} missing type`);
      if (!p.description)                      issues.push(`[${id}] params[${i}].${p.name||i} missing description`);
      if (typeof p.required !== 'boolean')     issues.push(`[${id}] params[${i}].${p.name||i} required must be boolean`);
    });

    // responseFields object form
    (ep.responseFields || []).forEach((r, i) => {
      if (!r || typeof r !== 'object' || Array.isArray(r)) {
        issues.push(`[${id}] responseFields[${i}] is not an object`); return;
      }
      if (!r.name)                             issues.push(`[${id}] responseFields[${i}] missing name`);
      if (!r.type)                             issues.push(`[${id}] responseFields[${i}].${r.name||i} missing type`);
      if (!r.description)                      issues.push(`[${id}] responseFields[${i}].${r.name||i} missing description`);
    });

    // every required param appears in requestExample
    const exampleKeys = new Set(Object.keys(ep.requestExample || {}));
    (ep.params || []).forEach(p => {
      if (p.required && !exampleKeys.has(p.name)) {
        issues.push(`[${id}] requestExample missing required param "${p.name}"`);
      }
    });

    // every playgroundControl key exists in params, or is marked uiOnly
    const paramNames = new Set((ep.params || []).map(p => p.name));
    (ep.playgroundControls || []).forEach(c => {
      if (c.uiOnly) return;
      if (!paramNames.has(c.key)) {
        issues.push(`[${id}] playgroundControl "${c.key}" has no matching param (add uiOnly:true if intentional)`);
      }
    });
  });

  if (issues.length) {
    console.warn(`[endpoint-data] ${issues.length} validation issue(s):`);
    issues.forEach(i => console.warn('  • ' + i));
  } else {
    console.info('[endpoint-data] validation OK — ' + Object.keys(ENDPOINTS).length + ' endpoints clean.');
  }
  return issues;
};
validateEndpoints();

// Helpers added in this layer.
const playgroundEnabledIds = () => endpointIds().filter(id => ENDPOINTS[id].playgroundEnabled !== false);
const seoTitle             = (id) => ENDPOINTS[id]?.seoTitle || '';
const seoDescription       = (id) => ENDPOINTS[id]?.seoDescription || '';

window.ENDPOINTS = ENDPOINTS;
window.GROUPS = GROUPS;
window.PRICE_USD_PER_MIN = PRICE_USD_PER_MIN;
window.PRICING_UNITS = PRICING_UNITS;
window.UNIT_LABELS = UNIT_LABELS;
window.endpointIds = endpointIds;
window.endpointIdsByGroup = endpointIdsByGroup;
window.endpointGroupCount = endpointGroupCount;
window.playgroundEnabledIds = playgroundEnabledIds;
window.priceFor = priceFor;
window.priceUnit = priceUnit;
window.pricingUnit = pricingUnit;
window.priceLabel = priceLabel;
window.docsHrefFor = docsHrefFor;
window.seoTitle = seoTitle;
window.seoDescription = seoDescription;
window.validateEndpoints = validateEndpoints;
