[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6DF2RKX9N1815HYNQAE6CZ3":3,"skill-same:01M6DF2RKX9N1815HYNQAE6CZ3":60},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":23,"publishedAt":35,"linkOnly":36,"markdownContent":37,"markdownBody":38,"frontMatter":39,"refName":40,"commitSha":41,"skillDirectory":42,"forksCount":14,"repositoryHtmlUrl":43,"repositoryHomepageUrl":6,"ownerHtmlUrl":44,"ownerBlogUrl":6,"ownerDescription":6,"updatedAt":45,"install":46,"resources":59},"01M6DF2RKX9N1815HYNQAE6CZ3","video-join-concat",null,"Use when joining\u002Fconcatenating multiple clips into ONE output, or assembling approved clips into an editable NLE timeline (CMX3600 EDL \u002F CSV for Premiere\u002FResolve). Covers both media_studio ffmpeg paths, concat demuxer and filter. NOT for re-encoding one clip (use video-convert).","Prekzursil\u002Fagent-skills-toolchain","Prekzursil","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F54636077?v=4","01M6DACZ1PCQHD99SCR1YXG28B","Andrei Mihai Visalon","01M6DACZ670WZF535V3WYYSS4Z",0,"MIT","https:\u002F\u002Fgithub.com\u002FPrekzursil\u002Fagent-skills-toolchain\u002Fblob\u002Fmain\u002Fskills\u002Fvideo-join-concat\u002FSKILL.md",[18],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000016","图像与多媒体","SCENARIO","使用场景",[24,25,26,27,28,29,30,31,32,33,34],"Video","join","video join concat","encoding","csv","timeline","Media","convert","use","clip","ffmpeg","2026-11-10 10:12:30",false,"---\nname: video-join-concat\ndescription: Use when joining\u002Fconcatenating multiple clips into ONE output, or assembling approved clips into an editable NLE timeline (CMX3600 EDL \u002F CSV for Premiere\u002FResolve). Covers both media_studio ffmpeg paths, concat demuxer and filter. NOT for re-encoding one clip (use video-convert).\n---\n\n# video-join-concat\n\nJoining clips in media_studio is **two unrelated jobs** that people conflate:\n\n1. **Render a single joined video** (one `.mp4` out) — ffmpeg, two algorithms.\n2. **Emit an editable timeline** (EDL\u002FCSV) — pure text, no ffmpeg, the NLE renders.\n\nPick the right one first. Re-encoding to produce an `.mp4` when the user wanted\nan editable rough cut is the most common waste; emitting an EDL when they wanted\na finished file is the opposite mistake.\n\nAll ffmpeg here goes through one runner, `media_studio.ffmpeg.run` (argv list\nonly, never `shell=True`), and every argv ends with `-progress pipe:1 -nostats`\nso progress is parsable. Source of truth:\n`sidecar\u002Fmedia_studio\u002Fffmpeg.py`.\n\n---\n\n## Decision: which join\n\n| Want | Use | Source |\n|---|---|---|\n| Stitch pieces you ENCODED YOURSELF with one codec contract | **concat demuxer** `-f concat -c copy` | `features\u002Freframe_multispeaker.py:716` `build_concat_argv` |\n| Join ARBITRARY user clips (unknown\u002Fmismatched codecs, res, fps) | **concat filter** `filter_complex …concat=n=N:v=1:a=1` (re-encode) | `features\u002Fdirector_op_engines.py:331` `build_join_argv` |\n| Cut spans OUT of ONE clip and re-join the kept parts | **trim+setpts concat filter** | `features\u002Ffillers.py:351` `build_segment_cut_argv` |\n| Hand an editor a back-to-back rough cut (no render) | **CMX3600 EDL \u002F CSV** | `features\u002Fnle_export.py` `build_edl` \u002F `build_csv` |\n\nRule of thumb baked into the code: **demuxer when you control the inputs**\n(every segment was just encoded to libx264\u002Faac\u002Fyuv420p, so a stream-copy join is\ninstant and lossless); **filter when you do not** (re-encode is the price of\ntolerating heterogeneity). See the explicit contrast comment at\n`reframe_multispeaker.py:724-728` and `director_op_engines.py:339-343`.\n\n---\n\n## Path A — concat DEMUXER (stream copy, fast, lossless)\n\nUsed after the reframe pipeline splits a clip into per-segment files that all\nshare **one codec contract**, then re-joins them.\n\nThe codec contract every segment is encoded with (`build_segment_argv`,\n`reframe_multispeaker.py:678-713`) — this is what makes the stream-copy join valid:\n\n```\nffmpeg -hide_banner -nostdin -y -ss \u003Cstart> -i in.mp4 -t \u003Cdur> \\\n  -filter_complex \u003Cgraph> -map [v] -map 0:a? \\\n  -c:v libx264 -preset medium -crf 18 -pix_fmt yuv420p \\\n  -c:a aac -b:a 192k -movflags +faststart \\\n  -progress pipe:1 -nostats seg_000.mp4\n```\n\nThe join itself (`build_concat_argv`, `reframe_multispeaker.py:716-749`):\n\n```\nffmpeg -hide_banner -nostdin -y -f concat -safe 0 -i list.txt \\\n  -c copy -movflags +faststart -progress pipe:1 -nostats out.mp4\n```\n\nThe manifest (`_write_concat_list`, `reframe_multispeaker.py:752-765`) — **one\n`file '\u003Cabspath>'` line per segment, in timeline order**:\n\n```\nfile '\u002Fabs\u002Fpath\u002Fseg_000.mp4'\nfile '\u002Fabs\u002Fpath\u002Fseg_001.mp4'\n```\n\nNon-negotiables this function enforces, each guarding a real failure:\n- **Absolute paths** — the manifest must be valid regardless of ffmpeg's CWD.\n- **Single-quote escaping** `'` → `'\\''` — a path with a quote otherwise\n  truncates the manifest line and ffmpeg loads the wrong\u002Fshort file.\n- **`-safe 0`** — required or the demuxer rejects absolute paths outright.\n- **`-map 0:a?`** per segment + `-c copy` on join → **tolerates a silent\n  source**: it copies whatever streams each piece has. The filter path cannot\n  do this (see Pitfalls).\n\n---\n\n## Path B — concat FILTER (re-encode, tolerant)\n\n`build_join_argv` (`director_op_engines.py:331-368`) — append `clips` after\n`in_path`, all into one timeline. Each input contributes exactly one v + one a:\n\n```\nffmpeg -hide_banner -nostdin -y -i in.mp4 -i b.mp4 -i c.mp4 \\\n  -filter_complex \"[0:v][0:a][1:v][1:a][2:v][2:a]concat=n=3:v=1:a=1[v][a]\" \\\n  -map [v] -map [a] -c:v libx264 -c:a aac \\\n  -progress pipe:1 -nostats out.mp4\n```\n\nThe graph is built programmatically:\n`streams = \"\".join(f\"[{i}:v][{i}:a]\" for i in range(n))` then\n`f\"{streams}concat=n={n}:v=1:a=1[v][a]\"` (`:350-351`). Output duration = sum of\nparts — `director_op_engines.py:342` calls that \"the strongest non-no-op proof\",\ni.e. assert it in any verification.\n\nInput guard (`_require_clips`, `:315-328`): `params['clips']` must be a non-empty\nlist of non-blank path strings, else `DirectorEngineError` (per-op `failed` +\nrollback) — a join with nothing to join is never a silent no-op.\n\n---\n\n## Path C — trim+setpts segment-cut join (one input)\n\n`build_segment_cut_argv` (`fillers.py:351-398`) — carve `keeps` out of ONE clip\nand concat the survivors. This is the dead-air \u002F filler-removal apply, but it is\na real intra-clip join and shows the **timestamp-reset discipline** that ALL\nfilter joins need:\n\n```\nffmpeg -hide_banner -nostdin -y -i in.mp4 -filter_complex \\\n \"[0:v]trim=start=1.000:end=4.500,setpts=PTS-STARTPTS[v0];\\\n  [0:a]atrim=start=1.000:end=4.500,asetpts=PTS-STARTPTS[a0];\\\n  [0:v]trim=start=6.200:end=9.000,setpts=PTS-STARTPTS[v1];\\\n  [0:a]atrim=start=6.200:end=9.000,asetpts=PTS-STARTPTS[a1];\\\n  [v0][a0][v1][a1]concat=n=2:v=1:a=1[v][a]\" \\\n -map [v] -map [a] -c:v libx264 -c:a aac -progress pipe:1 -nostats out.mp4\n```\n\nNotes from source: spans are filtered to `b > a` and an empty keep-set raises\n`ValueError` (`:364-366`); `trim`\u002F`atrim` are **decode-side → frame-accurate**;\nparts joined with `;`, the graph order is the play order. After a filter join\nthat changes durations, captions must be re-timed onto the new compressed\ntimeline (`remap_time`\u002F`remap_cues`, `fillers.py:305-347`) — the join shifts\nevery downstream cue.\n\n---\n\n## Path D — EDL \u002F CSV timeline (NO render)\n\n`nle_export.py` turns approved `Project.clips` into an **editable** timeline the\neditor imports and renders — no ffmpeg, pure `data -> str`. Use this when the\ndeliverable is a rough cut, not a file.\n\n- `clips_to_events(clips, fps)` (`:151`): each clip becomes one event with a\n  **source window** = the clip's ORIGINAL-video span (`sourceStart -> end`,\n  `durationSec` backstops a missing `end`) and a **record window laid\n  back-to-back** (`record_cursor` accumulates; each event's record-in is the\n  prior record-out — that contiguity is what stacks the clips into a rough cut,\n  docstring CONTRACT-NOTE `:23-27`). Zero-length clips are kept with a **1-frame\n  minimum** (`:171`) so no approved selection silently vanishes.\n- `build_edl` (`:204`): CMX3600, header `FCM: NON-DROP FRAME`, one `V  C` cut per\n  event, `* FROM CLIP NAME:` + `* COMMENT:` comment lines.\n- `build_csv` (`:236`): frozen `CSV_COLUMNS`, `HH:MM:SS:FF` timecodes, **CRLF**\n  line terminator (Excel-friendly) — `export` passes `newline=\"\"` for CSV so the\n  CRLF is not doubled (`:305`).\n- `fps` MUST be one of `FPS_CHOICES = (24,25,30,60)` — `normalize_fps` raises\n  otherwise. Frame math is non-drop: `frames_to_timecode` (`:86`).\n- Reel names are sanitized to **≤8 chars, A-Z0-9, uppercase** (`sanitize_reel`\n  `:107`), falling back to `AX`\u002F`AX2`… — CMX3600 reel field rules.\n- Empty `clips` still writes a valid header-only document (`:296`) — opens as an\n  empty-but-importable timeline.\n\nCompanion exports (not joins, but the same \"assemble for delivery\" family):\n`package_export.package` (one short → upload ZIP with `upload.json`),\n`export_presets` (server-side platform-target catalog: aspect, 20-60 s window,\ncaption style, reframe engine). Neither concatenates video.\n\n---\n\n## Pitfalls that actually bite\n\n1. **concat FILTER requires EVERY input to have BOTH a video and an audio\n   stream.** `concat=n=N:v=1:a=1` references `[i:a]` for each input; a silent\n   clip makes the graph fail. If any input may be silent, use the **demuxer**\n   path (`-c copy` + `0:a?`), which copies whatever streams exist\n   (`reframe_multispeaker.py:724-727`). This is the single biggest gotcha when\n   joining arbitrary user clips.\n\n2. **concat DEMUXER requires identical codec \u002F timebase \u002F SAR \u002F pix_fmt across\n   ALL parts.** Stream-copy does not resample or rescale. Mismatch →\n   A\u002FV drift, frozen frames, or a hard demuxer error. That is exactly why the\n   reframe pipeline encodes every segment to the SAME contract\n   (libx264\u002Fmedium\u002Fcrf18\u002Fyuv420p\u002Faac\u002F192k) BEFORE the copy-join\n   (`:670-672`). Never `-f concat -c copy` across files you did not encode\n   yourself — re-encode (Path B) instead.\n\n3. **Trim without resetting PTS corrupts the join.** Trimmed frames keep their\n   original presentation timestamps; concat then overlaps\u002Ffreezes them. Always\n   `setpts=PTS-STARTPTS` (video) and `asetpts=PTS-STARTPTS` (audio) after every\n   `trim`\u002F`atrim` (`fillers.py:372-373`). Forgetting one channel desyncs A\u002FV.\n\n4. **stderr pipe-buffer DEADLOCK (the \"29-min stuck clip 1\u002F5\").** ffmpeg writes\n   continuously to stderr (banner, stream info, libass\u002Ffont logs) even with\n   `-nostats`. If you `Popen(stderr=PIPE)` and never read it, the ~64 KB pipe\n   fills and the encode blocks **forever**. `ffmpeg.run` drains stderr on a\n   daemon thread keeping a bounded 40-line tail for error reporting\n   (`ffmpeg.py:254-272`). Use `ffmpeg.run`; do not hand-roll a Popen.\n\n5. **concat-list path safety.** Demuxer manifest needs absolute paths,\n   single-quote escaping (`'` → `'\\''`), and `-safe 0`. Skip any and ffmpeg\n   silently loads the wrong file or refuses the list\n   (`_write_concat_list` + `build_concat_argv`).\n\n6. **Input-seek vs frame accuracy.** Path A puts `-ss` BEFORE `-i`\n   (decode-then-discard input seek): fast, and crop coordinates stay in source\n   pixels (`:673-674`). For frame-accurate cuts within a graph use the\n   decode-side `trim` filter (Path C). Do not mix expectations: `-ss` before\n   `-i` can land on the nearest keyframe depending on build.\n\n7. **fps must be a member of `FPS_CHOICES`** for EDL\u002FCSV — arbitrary fps raises.\n   And EDL record timecodes are contiguous by construction; if you post-edit the\n   EDL, keep each record-in = previous record-out or the NLE lays gaps.\n\n8. **`durationSec` = sum of parts**, not max. After a filter concat, verify the\n   output duration ≈ Σ input durations (Path B) or Σ keep spans (Path C). A\n   no-op join (output == one input length) means the graph silently dropped\n   inputs.\n\n9. **`-movflags +faststart`** is set on every mp4 output here (demuxer + segment\n   encode) so the moov atom is up front for web\u002Fstreaming playback. Omitting it\n   produces a file that must fully download before it seeks.\n\n---\n\n## GPU \u002F HF-model notes\n\nNone of these paths use a GPU or any Hugging Face model. The joins are CPU\n`libx264`\u002F`aac` encodes (Paths B\u002FC) or pure stream-copy \u002F pure text (Paths A\u002FD).\nThe reframe pipeline that PRODUCES the segments uses heavy ML, but the\nconcat\u002FEDL stage is deliberately ML-free and import-light (`fillers.py:367`\nlazy-imports ffmpeg to keep the module light). If a join is slow it is the\nlibx264 re-encode (Path B\u002FC), not a model — prefer the demuxer (Path A) whenever\nthe codec contract allows it.\n\n---\n\n## Worked example — join 3 arbitrary user clips into one mp4\n\nInputs of unknown\u002Fmixed codecs, one possibly silent → **Path B is wrong** (the\nsilent clip breaks `:a=1`). Two correct options:\n\n**Option 1 (robust, what the code does for known-silent inputs): normalize then\ndemuxer-join.** Re-encode each to the contract, then stream-copy:\n\n```python\nfrom media_studio import ffmpeg\nfrom media_studio.features.reframe_multispeaker import build_concat_argv, _write_concat_list\n\n# 1. encode each input to the SAME contract (libx264\u002Fcrf18\u002Fyuv420p\u002Faac\u002F192k)\n#    — reuse build_segment_argv-style argv; map 0:a? so a silent input still works.\n# 2. write the manifest in timeline order, then copy-join:\n_write_concat_list(\"list.txt\", [\"seg_000.mp4\", \"seg_001.mp4\", \"seg_002.mp4\"])\ncode = ffmpeg.run(build_concat_argv(\"list.txt\", \"joined.mp4\"), total_sec=total)\nassert code == 0\n```\n\n**Option 2 (one pass, all inputs guaranteed to have audio): concat filter.**\n\n```python\nfrom media_studio.features.director_op_engines import build_join_argv\nargv = build_join_argv(\"a.mp4\", [\"b.mp4\", \"c.mp4\"], \"joined.mp4\")\n# -> ffmpeg ... -filter_complex \"[0:v][0:a][1:v][1:a][2:v][2:a]concat=n=3:v=1:a=1[v][a]\"\n#    -map [v] -map [a] -c:v libx264 -c:a aac ...\ncode = ffmpeg.run(argv, total_sec=dur_a + dur_b + dur_c)\nassert code == 0  # and probe output duration ≈ dur_a+dur_b+dur_c\n```\n\n**If the deliverable is an editable rough cut, not a file**, skip ffmpeg entirely:\n\n```python\nfrom media_studio.features import nle_export\nnle_export.export(project.clips, \"rough_cut.edl\", fmt=\"edl\", fps=30, title=\"Rough Cut\")\n# contiguous record timecodes lay the approved clips back-to-back; the editor renders.\n```\n\nVerify (always): `ffprobe` the joined output's duration and confirm it equals\nthe sum of inputs (filter) or the encoded segments (demuxer) — a single-frame or\nsingle-clip check is not proof of a correct join.\n","# video-join-concat\n\nJoining clips in media_studio is **two unrelated jobs** that people conflate:\n\n1. **Render a single joined video** (one `.mp4` out) — ffmpeg, two algorithms.\n2. **Emit an editable timeline** (EDL\u002FCSV) — pure text, no ffmpeg, the NLE renders.\n\nPick the right one first. Re-encoding to produce an `.mp4` when the user wanted\nan editable rough cut is the most common waste; emitting an EDL when they wanted\na finished file is the opposite mistake.\n\nAll ffmpeg here goes through one runner, `media_studio.ffmpeg.run` (argv list\nonly, never `shell=True`), and every argv ends with `-progress pipe:1 -nostats`\nso progress is parsable. Source of truth:\n`sidecar\u002Fmedia_studio\u002Fffmpeg.py`.\n\n---\n\n## Decision: which join\n\n| Want | Use | Source |\n|---|---|---|\n| Stitch pieces you ENCODED YOURSELF with one codec contract | **concat demuxer** `-f concat -c copy` | `features\u002Freframe_multispeaker.py:716` `build_concat_argv` |\n| Join ARBITRARY user clips (unknown\u002Fmismatched codecs, res, fps) | **concat filter** `filter_complex …concat=n=N:v=1:a=1` (re-encode) | `features\u002Fdirector_op_engines.py:331` `build_join_argv` |\n| Cut spans OUT of ONE clip and re-join the kept parts | **trim+setpts concat filter** | `features\u002Ffillers.py:351` `build_segment_cut_argv` |\n| Hand an editor a back-to-back rough cut (no render) | **CMX3600 EDL \u002F CSV** | `features\u002Fnle_export.py` `build_edl` \u002F `build_csv` |\n\nRule of thumb baked into the code: **demuxer when you control the inputs**\n(every segment was just encoded to libx264\u002Faac\u002Fyuv420p, so a stream-copy join is\ninstant and lossless); **filter when you do not** (re-encode is the price of\ntolerating heterogeneity). See the explicit contrast comment at\n`reframe_multispeaker.py:724-728` and `director_op_engines.py:339-343`.\n\n---\n\n## Path A — concat DEMUXER (stream copy, fast, lossless)\n\nUsed after the reframe pipeline splits a clip into per-segment files that all\nshare **one codec contract**, then re-joins them.\n\nThe codec contract every segment is encoded with (`build_segment_argv`,\n`reframe_multispeaker.py:678-713`) — this is what makes the stream-copy join valid:\n\n```\nffmpeg -hide_banner -nostdin -y -ss \u003Cstart> -i in.mp4 -t \u003Cdur> \\\n  -filter_complex \u003Cgraph> -map [v] -map 0:a? \\\n  -c:v libx264 -preset medium -crf 18 -pix_fmt yuv420p \\\n  -c:a aac -b:a 192k -movflags +faststart \\\n  -progress pipe:1 -nostats seg_000.mp4\n```\n\nThe join itself (`build_concat_argv`, `reframe_multispeaker.py:716-749`):\n\n```\nffmpeg -hide_banner -nostdin -y -f concat -safe 0 -i list.txt \\\n  -c copy -movflags +faststart -progress pipe:1 -nostats out.mp4\n```\n\nThe manifest (`_write_concat_list`, `reframe_multispeaker.py:752-765`) — **one\n`file '\u003Cabspath>'` line per segment, in timeline order**:\n\n```\nfile '\u002Fabs\u002Fpath\u002Fseg_000.mp4'\nfile '\u002Fabs\u002Fpath\u002Fseg_001.mp4'\n```\n\nNon-negotiables this function enforces, each guarding a real failure:\n- **Absolute paths** — the manifest must be valid regardless of ffmpeg's CWD.\n- **Single-quote escaping** `'` → `'\\''` — a path with a quote otherwise\n  truncates the manifest line and ffmpeg loads the wrong\u002Fshort file.\n- **`-safe 0`** — required or the demuxer rejects absolute paths outright.\n- **`-map 0:a?`** per segment + `-c copy` on join → **tolerates a silent\n  source**: it copies whatever streams each piece has. The filter path cannot\n  do this (see Pitfalls).\n\n---\n\n## Path B — concat FILTER (re-encode, tolerant)\n\n`build_join_argv` (`director_op_engines.py:331-368`) — append `clips` after\n`in_path`, all into one timeline. Each input contributes exactly one v + one a:\n\n```\nffmpeg -hide_banner -nostdin -y -i in.mp4 -i b.mp4 -i c.mp4 \\\n  -filter_complex \"[0:v][0:a][1:v][1:a][2:v][2:a]concat=n=3:v=1:a=1[v][a]\" \\\n  -map [v] -map [a] -c:v libx264 -c:a aac \\\n  -progress pipe:1 -nostats out.mp4\n```\n\nThe graph is built programmatically:\n`streams = \"\".join(f\"[{i}:v][{i}:a]\" for i in range(n))` then\n`f\"{streams}concat=n={n}:v=1:a=1[v][a]\"` (`:350-351`). Output duration = sum of\nparts — `director_op_engines.py:342` calls that \"the strongest non-no-op proof\",\ni.e. assert it in any verification.\n\nInput guard (`_require_clips`, `:315-328`): `params['clips']` must be a non-empty\nlist of non-blank path strings, else `DirectorEngineError` (per-op `failed` +\nrollback) — a join with nothing to join is never a silent no-op.\n\n---\n\n## Path C — trim+setpts segment-cut join (one input)\n\n`build_segment_cut_argv` (`fillers.py:351-398`) — carve `keeps` out of ONE clip\nand concat the survivors. This is the dead-air \u002F filler-removal apply, but it is\na real intra-clip join and shows the **timestamp-reset discipline** that ALL\nfilter joins need:\n\n```\nffmpeg -hide_banner -nostdin -y -i in.mp4 -filter_complex \\\n \"[0:v]trim=start=1.000:end=4.500,setpts=PTS-STARTPTS[v0];\\\n  [0:a]atrim=start=1.000:end=4.500,asetpts=PTS-STARTPTS[a0];\\\n  [0:v]trim=start=6.200:end=9.000,setpts=PTS-STARTPTS[v1];\\\n  [0:a]atrim=start=6.200:end=9.000,asetpts=PTS-STARTPTS[a1];\\\n  [v0][a0][v1][a1]concat=n=2:v=1:a=1[v][a]\" \\\n -map [v] -map [a] -c:v libx264 -c:a aac -progress pipe:1 -nostats out.mp4\n```\n\nNotes from source: spans are filtered to `b > a` and an empty keep-set raises\n`ValueError` (`:364-366`); `trim`\u002F`atrim` are **decode-side → frame-accurate**;\nparts joined with `;`, the graph order is the play order. After a filter join\nthat changes durations, captions must be re-timed onto the new compressed\ntimeline (`remap_time`\u002F`remap_cues`, `fillers.py:305-347`) — the join shifts\nevery downstream cue.\n\n---\n\n## Path D — EDL \u002F CSV timeline (NO render)\n\n`nle_export.py` turns approved `Project.clips` into an **editable** timeline the\neditor imports and renders — no ffmpeg, pure `data -> str`. Use this when the\ndeliverable is a rough cut, not a file.\n\n- `clips_to_events(clips, fps)` (`:151`): each clip becomes one event with a\n  **source window** = the clip's ORIGINAL-video span (`sourceStart -> end`,\n  `durationSec` backstops a missing `end`) and a **record window laid\n  back-to-back** (`record_cursor` accumulates; each event's record-in is the\n  prior record-out — that contiguity is what stacks the clips into a rough cut,\n  docstring CONTRACT-NOTE `:23-27`). Zero-length clips are kept with a **1-frame\n  minimum** (`:171`) so no approved selection silently vanishes.\n- `build_edl` (`:204`): CMX3600, header `FCM: NON-DROP FRAME`, one `V  C` cut per\n  event, `* FROM CLIP NAME:` + `* COMMENT:` comment lines.\n- `build_csv` (`:236`): frozen `CSV_COLUMNS`, `HH:MM:SS:FF` timecodes, **CRLF**\n  line terminator (Excel-friendly) — `export` passes `newline=\"\"` for CSV so the\n  CRLF is not doubled (`:305`).\n- `fps` MUST be one of `FPS_CHOICES = (24,25,30,60)` — `normalize_fps` raises\n  otherwise. Frame math is non-drop: `frames_to_timecode` (`:86`).\n- Reel names are sanitized to **≤8 chars, A-Z0-9, uppercase** (`sanitize_reel`\n  `:107`), falling back to `AX`\u002F`AX2`… — CMX3600 reel field rules.\n- Empty `clips` still writes a valid header-only document (`:296`) — opens as an\n  empty-but-importable timeline.\n\nCompanion exports (not joins, but the same \"assemble for delivery\" family):\n`package_export.package` (one short → upload ZIP with `upload.json`),\n`export_presets` (server-side platform-target catalog: aspect, 20-60 s window,\ncaption style, reframe engine). Neither concatenates video.\n\n---\n\n## Pitfalls that actually bite\n\n1. **concat FILTER requires EVERY input to have BOTH a video and an audio\n   stream.** `concat=n=N:v=1:a=1` references `[i:a]` for each input; a silent\n   clip makes the graph fail. If any input may be silent, use the **demuxer**\n   path (`-c copy` + `0:a?`), which copies whatever streams exist\n   (`reframe_multispeaker.py:724-727`). This is the single biggest gotcha when\n   joining arbitrary user clips.\n\n2. **concat DEMUXER requires identical codec \u002F timebase \u002F SAR \u002F pix_fmt across\n   ALL parts.** Stream-copy does not resample or rescale. Mismatch →\n   A\u002FV drift, frozen frames, or a hard demuxer error. That is exactly why the\n   reframe pipeline encodes every segment to the SAME contract\n   (libx264\u002Fmedium\u002Fcrf18\u002Fyuv420p\u002Faac\u002F192k) BEFORE the copy-join\n   (`:670-672`). Never `-f concat -c copy` across files you did not encode\n   yourself — re-encode (Path B) instead.\n\n3. **Trim without resetting PTS corrupts the join.** Trimmed frames keep their\n   original presentation timestamps; concat then overlaps\u002Ffreezes them. Always\n   `setpts=PTS-STARTPTS` (video) and `asetpts=PTS-STARTPTS` (audio) after every\n   `trim`\u002F`atrim` (`fillers.py:372-373`). Forgetting one channel desyncs A\u002FV.\n\n4. **stderr pipe-buffer DEADLOCK (the \"29-min stuck clip 1\u002F5\").** ffmpeg writes\n   continuously to stderr (banner, stream info, libass\u002Ffont logs) even with\n   `-nostats`. If you `Popen(stderr=PIPE)` and never read it, the ~64 KB pipe\n   fills and the encode blocks **forever**. `ffmpeg.run` drains stderr on a\n   daemon thread keeping a bounded 40-line tail for error reporting\n   (`ffmpeg.py:254-272`). Use `ffmpeg.run`; do not hand-roll a Popen.\n\n5. **concat-list path safety.** Demuxer manifest needs absolute paths,\n   single-quote escaping (`'` → `'\\''`), and `-safe 0`. Skip any and ffmpeg\n   silently loads the wrong file or refuses the list\n   (`_write_concat_list` + `build_concat_argv`).\n\n6. **Input-seek vs frame accuracy.** Path A puts `-ss` BEFORE `-i`\n   (decode-then-discard input seek): fast, and crop coordinates stay in source\n   pixels (`:673-674`). For frame-accurate cuts within a graph use the\n   decode-side `trim` filter (Path C). Do not mix expectations: `-ss` before\n   `-i` can land on the nearest keyframe depending on build.\n\n7. **fps must be a member of `FPS_CHOICES`** for EDL\u002FCSV — arbitrary fps raises.\n   And EDL record timecodes are contiguous by construction; if you post-edit the\n   EDL, keep each record-in = previous record-out or the NLE lays gaps.\n\n8. **`durationSec` = sum of parts**, not max. After a filter concat, verify the\n   output duration ≈ Σ input durations (Path B) or Σ keep spans (Path C). A\n   no-op join (output == one input length) means the graph silently dropped\n   inputs.\n\n9. **`-movflags +faststart`** is set on every mp4 output here (demuxer + segment\n   encode) so the moov atom is up front for web\u002Fstreaming playback. Omitting it\n   produces a file that must fully download before it seeks.\n\n---\n\n## GPU \u002F HF-model notes\n\nNone of these paths use a GPU or any Hugging Face model. The joins are CPU\n`libx264`\u002F`aac` encodes (Paths B\u002FC) or pure stream-copy \u002F pure text (Paths A\u002FD).\nThe reframe pipeline that PRODUCES the segments uses heavy ML, but the\nconcat\u002FEDL stage is deliberately ML-free and import-light (`fillers.py:367`\nlazy-imports ffmpeg to keep the module light). If a join is slow it is the\nlibx264 re-encode (Path B\u002FC), not a model — prefer the demuxer (Path A) whenever\nthe codec contract allows it.\n\n---\n\n## Worked example — join 3 arbitrary user clips into one mp4\n\nInputs of unknown\u002Fmixed codecs, one possibly silent → **Path B is wrong** (the\nsilent clip breaks `:a=1`). Two correct options:\n\n**Option 1 (robust, what the code does for known-silent inputs): normalize then\ndemuxer-join.** Re-encode each to the contract, then stream-copy:\n\n```python\nfrom media_studio import ffmpeg\nfrom media_studio.features.reframe_multispeaker import build_concat_argv, _write_concat_list\n\n# 1. encode each input to the SAME contract (libx264\u002Fcrf18\u002Fyuv420p\u002Faac\u002F192k)\n#    — reuse build_segment_argv-style argv; map 0:a? so a silent input still works.\n# 2. write the manifest in timeline order, then copy-join:\n_write_concat_list(\"list.txt\", [\"seg_000.mp4\", \"seg_001.mp4\", \"seg_002.mp4\"])\ncode = ffmpeg.run(build_concat_argv(\"list.txt\", \"joined.mp4\"), total_sec=total)\nassert code == 0\n```\n\n**Option 2 (one pass, all inputs guaranteed to have audio): concat filter.**\n\n```python\nfrom media_studio.features.director_op_engines import build_join_argv\nargv = build_join_argv(\"a.mp4\", [\"b.mp4\", \"c.mp4\"], \"joined.mp4\")\n# -> ffmpeg ... -filter_complex \"[0:v][0:a][1:v][1:a][2:v][2:a]concat=n=3:v=1:a=1[v][a]\"\n#    -map [v] -map [a] -c:v libx264 -c:a aac ...\ncode = ffmpeg.run(argv, total_sec=dur_a + dur_b + dur_c)\nassert code == 0  # and probe output duration ≈ dur_a+dur_b+dur_c\n```\n\n**If the deliverable is an editable rough cut, not a file**, skip ffmpeg entirely:\n\n```python\nfrom media_studio.features import nle_export\nnle_export.export(project.clips, \"rough_cut.edl\", fmt=\"edl\", fps=30, title=\"Rough Cut\")\n# contiguous record timecodes lay the approved clips back-to-back; the editor renders.\n```\n\nVerify (always): `ffprobe` the joined output's duration and confirm it equals\nthe sum of inputs (filter) or the encoded segments (demuxer) — a single-frame or\nsingle-clip check is not proof of a correct join.\n",{"name":5,"description":7},"main","a298c929b6cbd662e1fce54302ce520d5ceea1a5","skills\u002Fvideo-join-concat","https:\u002F\u002Fgithub.com\u002FPrekzursil\u002Fagent-skills-toolchain","https:\u002F\u002Fgithub.com\u002FPrekzursil","2026-11-02 14:30:57",{"source":8,"skillName":5,"commands":47,"prompt":57,"zipAvailable":58},[48,51,54],{"tool":49,"command":50},"npx","npx skills add Prekzursil\u002Fagent-skills-toolchain --skill video-join-concat",{"tool":52,"command":53},"bunx","bunx skills add Prekzursil\u002Fagent-skills-toolchain --skill video-join-concat",{"tool":55,"command":56},"pnpm","pnpm dlx skills add Prekzursil\u002Fagent-skills-toolchain --skill video-join-concat","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002FPrekzursil\u002Fagent-skills-toolchain\u002Fblob\u002Fmain\u002Fskills\u002Fvideo-join-concat\u002FSKILL.md",true,[],[61,82,111,138,152,168],{"skillNo":62,"name":63,"nameZh":6,"description":64,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":65,"categories":66,"keywords":67,"publishedAt":35},"01M6DF7D7478R55P46REYH4QAC","clarify-requirements","Resolve genuine pre-execution ambiguity by scanning repo context, generating 1-6 grounded options, and asking 1-3 focused questions via AskUserQuestion. NOT for mid-task use (injection risk); NOT when the goal is already unambiguous; use to disambiguate vague prompts before any file edits.","https:\u002F\u002Fgithub.com\u002FPrekzursil\u002Fagent-skills-toolchain\u002Fblob\u002Fmain\u002Ftools\u002Fworkspace-hooks\u002Fskills\u002Fclarify-requirements\u002FSKILL.md",[],[68,69,70,71,72,73,74,75,76,32,77,78,79,80,81],"clarify requirements","clarify","requirements","risk","options","task","prompts","goal","scanning","context","repo","injection","execution","file",{"skillNo":83,"name":84,"nameZh":6,"description":85,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":86,"categories":87,"keywords":91,"publishedAt":35},"01M6DF79CA4BHJS66J8EZDBPWJ","openmm-md","Run FREE, open-source, Windows-native GPU molecular dynamics via the openmm-md MCP (OpenMM + CUDA on the local NVIDIA GPU). Use for protein \u002F protein-ligand MD when you want a free stack or are on Windows without a Schrodinger license. Covers the full workflow (prepare, solvate, minimize, equilibrate, production, analyze, mmgbsa) AND an honest decision guide vs Schrodinger Desmond, including how to run Desmond legitimately on Linux or a cloud GPU with a real license. This is a deliberately LESSER free alternative and does NOT replace the schrodinger MCP tools. Do NOT use it to pick a free tool when a validated FEP+, WaterMap, IFD-MD, or Prime MM-GBSA result is actually required.","https:\u002F\u002Fgithub.com\u002FPrekzursil\u002Fagent-skills-toolchain\u002Fblob\u002Fmain\u002Ftools\u002Fmcp\u002Fopenmm-md-mcp\u002Fskill\u002Fopenmm-md\u002FSKILL.md",[88],{"categoryNo":89,"categoryName":90,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000023","Skill与智能体开发",[92,93,94,95,96,97,98,99,100,101,102,103,104,105,32,106,107,108,109,110],"openmm md","how","decision","pick","linux","analyze","tool","stack","open","NVIDIA","gpu","license","workflow","windows","free","molecular dynamics","local","do","MCP",{"skillNo":112,"name":113,"nameZh":6,"description":114,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":115,"categories":116,"keywords":123,"publishedAt":35},"01M6DF76C1MG85VEWP8VZCBNMN","visual-review","Take screenshots of web pages and UI using Playwright for visual review and iteration","https:\u002F\u002Fgithub.com\u002FPrekzursil\u002Fagent-skills-toolchain\u002Fblob\u002Fmain\u002Ftools\u002Feditor\u002Fmetaswarm\u002Fskills\u002Fvisual-review\u002FSKILL.md",[117,120],{"categoryNo":118,"categoryName":119,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000003","演示文稿",{"categoryNo":121,"categoryName":122,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000008","测试与质量",[124,125,126,127,128,129,130,131,132,133,93,134,135,136,137],"review","visual","visual review","pages","web","false","iteration","take","check","slides","ui","screenshot","playwright","screenshots",{"skillNo":139,"name":140,"nameZh":6,"description":141,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":142,"categories":143,"keywords":144,"publishedAt":35},"01M6DF73AQVCBZKEN0YXEX9FXP","status","Diagnostic status report — shows metaswarm installation state, project setup, and potential issues","https:\u002F\u002Fgithub.com\u002FPrekzursil\u002Fagent-skills-toolchain\u002Fblob\u002Fmain\u002Ftools\u002Feditor\u002Fmetaswarm\u002Fskills\u002Fstatus\u002FSKILL.md",[],[140,145,146,147,148,149,150,151],"setup","report","project","installation","project setup","issues","state",{"skillNo":153,"name":154,"nameZh":6,"description":155,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":156,"categories":157,"keywords":159,"publishedAt":35},"01M6DF6X6C2XB0P6GX76P7TTYV","start","Use when starting work on any task, when the user mentions metaswarm, or when the user wants to begin tracked development work","https:\u002F\u002Fgithub.com\u002FPrekzursil\u002Fagent-skills-toolchain\u002Fblob\u002Fmain\u002Ftools\u002Feditor\u002Fmetaswarm\u002Fskills\u002Fstart\u002FSKILL.md",[158],{"categoryNo":89,"categoryName":90,"taxonomyType":21,"taxonomyTypeDesc":22},[154,160,73,161,162,163,32,164,165,166,167],"start issue","issue","ready","work on issue","user","work","work on","development",{"skillNo":169,"name":145,"nameZh":6,"description":170,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":171,"categories":172,"keywords":173,"publishedAt":35},"01M6DF6QMJ9AJKFKHHKRT005PQ","Interactive project setup — detects your project, configures metaswarm, writes project-local files","https:\u002F\u002Fgithub.com\u002FPrekzursil\u002Fagent-skills-toolchain\u002Fblob\u002Fmain\u002Ftools\u002Feditor\u002Fmetaswarm\u002Fskills\u002Fsetup\u002FSKILL.md",[],[145,147,108,174,149,175],"files","interactive"]