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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ This variant takes **two inputs** — the source video and a destination image:
Because the endpoint is pinned to your image, it's ideal for bridging a clip to a known next shot or landing on a specific final frame.
:::

The **Concatenate Videos** node smart-renders compatible H.264 MP4 clips by preserving compressed video outside transition regions. Clips must share dimensions and frame rate and use compatible encoding settings. Other inputs, or a failed smart-render validation, automatically use the regular full-encoding path. Set the node's **Smart Render** input to `false` to always use full encoding.

---

## Bundled MiniMax H3 workflows
Expand Down
383 changes: 267 additions & 116 deletions invokeai/app/invocations/video_concat.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@
"type": "invocation",
"data": {
"id": "video_output",
"version": "1.2.0",
"version": "1.3.0",
"nodePack": "invokeai",
"label": "",
"notes": "Joins source + extension. `transition_frames` arrives over an edge from context_frames -- the crossfade must consume exactly the held frames or they play twice.",
Expand Down Expand Up @@ -493,6 +493,12 @@
"label": "",
"description": "",
"value": "match_first"
},
"smart_render": {
"name": "smart_render",
"label": "",
"description": "",
"value": true
}
},
"isOpen": true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,7 @@
"type": "invocation",
"data": {
"id": "4b32c22b-679d-4d5e-a167-c3309265fcd9",
"version": "1.2.0",
"version": "1.3.0",
"nodePack": "invokeai",
"label": "",
"notes": "Joins the trimmed source and the generated continuation, cross-fading over the hand-off frame so the seam is not visible.",
Expand Down Expand Up @@ -657,6 +657,12 @@
"label": "",
"description": "",
"value": "match_first"
},
"smart_render": {
"name": "smart_render",
"label": "",
"description": "",
"value": true
}
},
"isOpen": true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,7 @@
"type": "invocation",
"data": {
"id": "4b32c22b-679d-4d5e-a167-c3309265fcd9",
"version": "1.2.0",
"version": "1.3.0",
"nodePack": "invokeai",
"label": "",
"notes": "Joins the source clip and the generated continuation. Size Mismatch is 'match_first', so the generated clip is resampled onto the source clip's canvas -- the model renders onto its own fixed canvas family and will rarely match your footage, and without this the join fails outright. Your source keeps its own resolution; the generated segment is the part that gets rescaled.",
Expand Down Expand Up @@ -615,6 +615,12 @@
"label": "",
"description": "",
"value": "match_first"
},
"smart_render": {
"name": "smart_render",
"label": "",
"description": "",
"value": true
}
},
"dynamicInputTemplates": {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,7 @@
"type": "invocation",
"data": {
"id": "4b32c22b-679d-4d5e-a167-c3309265fcd9",
"version": "1.2.0",
"version": "1.3.0",
"nodePack": "invokeai",
"label": "",
"notes": "Joins the source clip and the generated continuation. Size Mismatch is 'match_first', so the generated clip is resampled onto the source clip's canvas -- the model renders onto its own fixed canvas family and will rarely match your footage, and without this the join fails outright. Your source keeps its own resolution; the generated segment is the part that gets rescaled.",
Expand Down Expand Up @@ -609,6 +609,12 @@
"label": "",
"description": "",
"value": "match_first"
},
"smart_render": {
"name": "smart_render",
"label": "",
"description": "",
"value": true
}
},
"isOpen": true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@
"type": "invocation",
"data": {
"id": "video_output",
"version": "1.2.0",
"version": "1.3.0",
"nodePack": "invokeai",
"label": "",
"notes": "Joins source + extension. `transition_frames` arrives over an edge from context_frames -- the crossfade must consume exactly the held frames or they play twice.",
Expand Down Expand Up @@ -497,6 +497,12 @@
"label": "",
"description": "",
"value": "match_first"
},
"smart_render": {
"name": "smart_render",
"label": "",
"description": "",
"value": true
}
},
"isOpen": true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1062,7 +1062,7 @@
"type": "invocation",
"data": {
"id": "4b32c22b-679d-4d5e-a167-c3309265fcd9",
"version": "1.2.0",
"version": "1.3.0",
"nodePack": "invokeai",
"label": "",
"notes": "Joins the source clip and the generated continuation. Size Mismatch is 'match_first', so the generated clip is resampled onto the source clip's canvas -- the model renders onto its own fixed canvas family and will rarely match your footage, and without this the join fails outright. Your source keeps its own resolution; the generated segment is the part that gets rescaled.",
Expand Down Expand Up @@ -1107,6 +1107,12 @@
"label": "",
"description": "",
"value": "match_first"
},
"smart_render": {
"name": "smart_render",
"label": "",
"description": "",
"value": true
}
},
"isOpen": true,
Expand Down
64 changes: 61 additions & 3 deletions invokeai/app/util/h264_splice.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@
_IDENTITY_MATRIX = (0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x40000000)


class H264AccessPoint(NamedTuple):
"""An IDR sample and its exact position on both the packet and presentation timelines."""

sample_index: int
frame_index: int
dts: int
pts: int


class H264Layout(NamedTuple):
"""What a splice needs to know about an MP4's first video track."""

Expand All @@ -93,6 +102,10 @@ class H264Layout(NamedTuple):
Two tracks can share a joined track only if these bytes match."""
frame_duration: Optional[int]
"""Every sample's duration in media ticks, or None if they are not all equal."""
time_scale: int
"""The media clock rate in ticks per second."""
access_points: tuple[H264AccessPoint, ...]
"""IDR points with sample and presentation indices, DTS and PTS; empty unless timing is verified CFR."""


class SpliceError(RuntimeError):
Expand Down Expand Up @@ -148,7 +161,7 @@ def read_h264_layout(path: Path) -> Optional[H264Layout]:
fh, find_box(fh, *edts, b"elst", max_boxes=_MAX_CHILD_BOXES), stbl, movie_timescale, media_timescale
):
return None
return _read_video_stbl(fh, *stbl)
return _read_video_stbl(fh, *stbl, media_timescale)
return None


Expand Down Expand Up @@ -328,7 +341,7 @@ def _is_idr(fh: BinaryIO, offset: int, size: int, length_size: int) -> bool:
return False


def _read_video_stbl(fh: BinaryIO, stbl_start: int, stbl_end: int) -> Optional[H264Layout]:
def _read_video_stbl(fh: BinaryIO, stbl_start: int, stbl_end: int, time_scale: int) -> Optional[H264Layout]:
stbl = (stbl_start, stbl_end)
stsd = find_box(fh, *stbl, b"stsd", max_boxes=_MAX_CHILD_BOXES)
stsz = find_box(fh, *stbl, b"stsz", max_boxes=_MAX_CHILD_BOXES)
Expand Down Expand Up @@ -407,16 +420,61 @@ def _read_video_stbl(fh: BinaryIO, stbl_start: int, stbl_end: int) -> Optional[H
)

runs = _read_stts(fh, stbl)
if runs is None:
if runs is None or sum(count for count, _ in runs) != sample_count or any(delta <= 0 for _, delta in runs):
return None
deltas = {delta for _, delta in runs}
frame_duration = deltas.pop() if len(deltas) == 1 else None
access_points: tuple[H264AccessPoint, ...] = ()
if frame_duration is not None:
offsets = _read_composition_offsets(fh, stbl, sample_count)
if offsets is not None:
dts = np.arange(sample_count, dtype=np.int64) * frame_duration
pts = dts + offsets
sorted_pts = np.sort(pts)
if sample_count == 1 or np.all(np.diff(sorted_pts) == frame_duration):
access_points = tuple(
H264AccessPoint(
sample_index=k,
frame_index=int(np.searchsorted(sorted_pts, pts[k])),
dts=int(dts[k]),
pts=int(pts[k]),
)
for k in idr_samples
)
return H264Layout(
sample_count=sample_count,
sync_samples=idr_samples,
decoder_config=decoder_config,
sample_entry=sample_entry,
frame_duration=frame_duration,
time_scale=time_scale,
access_points=access_points,
)


def _read_composition_offsets(fh: BinaryIO, stbl: tuple[int, int], sample_count: int) -> Optional[np.ndarray]:
"""Expand the bounded ``ctts`` runs, or return zero offsets when the table is absent."""
ctts = find_box(fh, *stbl, b"ctts", max_boxes=_MAX_CHILD_BOXES)
if ctts is None:
return np.zeros(sample_count, dtype=np.int64)
if ctts[0] + 8 > ctts[1]:
return None
fh.seek(ctts[0])
version = fh.read(1)[0]
if version not in (0, 1):
return None
fh.seek(ctts[0] + 4)
(run_count,) = struct.unpack(">I", fh.read(4))
if not 0 < run_count <= _MAX_SAMPLES or ctts[0] + 8 + 8 * run_count > ctts[1]:
return None
fh.seek(ctts[0] + 8)
fmt = ">II" if version == 0 else ">Ii"
runs = [struct.unpack(fmt, fh.read(8)) for _ in range(run_count)]
if any(count == 0 for count, _ in runs) or sum(count for count, _ in runs) != sample_count:
return None
return np.repeat(
np.fromiter((offset for _, offset in runs), dtype=np.int64, count=run_count),
np.fromiter((count for count, _ in runs), dtype=np.int64, count=run_count),
)


Expand Down
Loading
Loading