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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions cli/ffrwd/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
is_annotation_argument,
kwarg_name,
parse,
subscript_index,
)
from .project import (
RESERVED_NAMESPACES,
Expand Down Expand Up @@ -201,6 +202,9 @@ class RuntimeLateral:
as written. `params` are the values bound per message and `written` the
ones the call wrote itself, as SQL. `needs` are the script's own
definitions an instance calls, in the order the script wrote them.
`sizes` are the picture sizes the body declares: each video column that
is an input's own picture with a ``shape`` naming its width and height,
as ``(column, width, height)``.
"""

function: str
Expand All @@ -212,6 +216,7 @@ class RuntimeLateral:
columns: tuple[Parameter, ...]
written: Mapping[str, str]
needs: tuple[str, ...]
sizes: tuple[tuple[str, int, int], ...] = ()


# Names a definition may not claim: the dialect's own FROM item, the two
Expand Down Expand Up @@ -3689,6 +3694,72 @@ def _runs_per_message(function: _Function) -> bool:
)


def _declared_sizes(function: _Function) -> tuple[tuple[str, int, int], ...]:
"""The picture size a run-time lateral's body declares for each video
column that is an input's own first picture, ``f.video[1] AS video`` over
``input(url, shape => STRUCT(1920 AS width, 1080 AS height, ...)) f``:
``(column, width, height)``. A column made any other way, or a shape
whose size is not written as numbers, has none.
"""
body = function.body
items: list[exp.Expr] = []
found = body.args.get("from_")
if isinstance(found, exp.From):
items.append(found.this)
items += [join.this for join in body.args.get("joins") or []]
shapes: dict[str, tuple[int, int]] = {}
for item in items:
call = item.this if isinstance(item, exp.Table) else None
alias = item.args.get("alias") if isinstance(item, exp.Table) else None
if not isinstance(call, exp.Anonymous) or str(call.this).lower() != "input":
continue
if not isinstance(alias, exp.TableAlias) or alias.this is None:
continue
for argument in call.expressions:
if not isinstance(argument, exp.Kwarg) or kwarg_name(argument) != "shape":
continue
struct = argument.expression
if not isinstance(struct, exp.Struct):
continue
fields: dict[str, object] = {}
for entry in struct.expressions:
if isinstance(entry, exp.PropertyEQ) and isinstance(entry.expression, exp.Literal):
literal = entry.expression
fields[str(entry.this.name).lower()] = (
None if literal.is_string else literal.to_py()
)
width, height = fields.get("width"), fields.get("height")
if (
isinstance(width, int)
and isinstance(height, int)
and not isinstance(width, bool)
and not isinstance(height, bool)
and width > 0
and height > 0
):
shapes[_ident_name(alias.this)] = (width, height)
sizes: list[tuple[str, int, int]] = []
for column in function.columns or ():
if column.type != _WASM_STREAM:
continue
for selected in body.expressions:
named = selected.alias_or_name if isinstance(selected, exp.Alias) else None
if named != column.name:
continue
read = selected.this
if not (
isinstance(read, exp.Bracket)
and isinstance(read.this, exp.Column)
and read.this.name == "video"
and subscript_index(read) == 1
):
continue
size = shapes.get(read.this.table)
if size is not None:
sizes.append((column.name, *size))
return tuple(sizes)


def _check_runs_per_message(function: _Function, anchor: exp.Expr) -> None:
"""A run-time lateral's declaration: the streams a feeder takes out, one
value per parameter after the data stream, and a body that reads no
Expand Down Expand Up @@ -5920,6 +5991,7 @@ def _expand_runtime_lateral(
columns=function.columns or (),
written=written,
needs=self._needs(function),
sizes=_declared_sizes(function),
)
line, col = _pos(item)
columns: dict[str, exp.Expr] = {}
Expand Down
24 changes: 22 additions & 2 deletions cli/ffrwd/lower.py
Original file line number Diff line number Diff line change
Expand Up @@ -16525,12 +16525,16 @@ def _node_instance(
and all(_lateral_parts(stream.ref) is not None for stream in value.streams)
}
# A lateral's port is bound, to the host's own stream, so the shape
# is asked with it; what it binds in the network is nothing.
# is asked with it; what it binds in the network is nothing. Its
# picture's hint carries the size the lateral's body declares, which
# the host conforms the feed to.
bound = [
Binding(
param.name,
tuple(
StreamHint(None if param.name in fed else self._stream_rate(stream.ref))
StreamHint(None, self._lateral_size(stream.ref))
if param.name in fed
else StreamHint(self._stream_rate(stream.ref))
for stream in streams[param.name].streams
),
)
Expand Down Expand Up @@ -16695,6 +16699,22 @@ def _node_instance(
self._node_refs[key] = instance
return instance

def _lateral_size(self, ref: FrameRef) -> tuple[int, int] | None:
"""The picture size the body of the run-time lateral making `ref`
declares for it, where it declares one."""
parts = _lateral_parts(ref)
if parts is None:
return None
key, column = parts
return next(
(
(width, height)
for named, width, height in self._laterals[key].declared.sizes
if named == column
),
None,
)

def _stream_rate(self, ref: FrameRef) -> Fraction | None:
"""The rate a node's shape is told the stream `ref` runs at."""
return stream_rate(self.graph, self.probes, self._made_shape, ref)
Expand Down
26 changes: 17 additions & 9 deletions cli/ffrwd/shapes.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,11 @@ def to_dict(self) -> dict[str, object]:
class StreamHint:
"""What the compiler knows of one bound stream before the run: a video
stream's frame rate or an audio stream's sample rate, None where nothing
settles it."""
settles it; and, for a picture fed on a port, the width and height its
source declares, which the sidecar conforms the feed to."""

rate: Fraction | None = None
size: tuple[int, int] | None = None


@dataclass(frozen=True)
Expand All @@ -251,21 +253,27 @@ def bound_json(bound: Sequence[Binding]) -> str:
[
{
"input": binding.input,
"streams": [
{
"rate": None
if hint.rate is None
else {"num": hint.rate.numerator, "den": hint.rate.denominator}
}
for hint in binding.streams
],
"streams": [_hint_json(hint) for hint in binding.streams],
}
for binding in bound
],
separators=(",", ":"),
)


def _hint_json(hint: StreamHint) -> dict[str, object]:
"""One stream's hint as ``--bound`` takes it: its rate, and its size
where it has one."""
found: dict[str, object] = {
"rate": None
if hint.rate is None
else {"num": hint.rate.numerator, "den": hint.rate.denominator}
}
if hint.size is not None:
found["width"], found["height"] = hint.size
return found


def _reject(message: str, hint: str = _UNKNOWN_HINT) -> FfrwdError:
return FfrwdError(ErrorCode.UNSUPPORTED_SQL, message, hint=hint)

Expand Down
35 changes: 35 additions & 0 deletions cli/tests/test_laterals.py
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,41 @@ def test_a_port_param_taking_one_or_many_is_handed_a_list_for_two_laterals() ->
assert _compose_node(plan).args["port"] == ports


_CAM = """CREATE FUNCTION cam(launch data_stream, url text)
RETURNS TABLE(video video_stream) AS $$
SELECT c.video[1] AS video
FROM input(url, format => 'mpegts', shape => STRUCT(1920 AS width, 1080 AS height, 60 AS fps)) c
$$ LANGUAGE sql;"""


def test_a_laterals_declared_picture_size_is_its_streams_hint_in_port_order() -> None:
"""The cam's body reads an input whose shape declares 1920x1080, so its
stream's hint carries that size; the ad's body scales to what each
message binds, so its hint has none. The hints are in the order of the
ports the module is handed, which is how the host pairs them."""
query = _TWO_ADS.replace("ARRAY[ad.video, lbar.video]", "ARRAY[m.video, ad.video]").replace(
"LATERAL play(prog.d) ad, LATERAL play(prog.d) lbar",
"LATERAL cam(prog.d) m, LATERAL play(prog.d) ad",
)
modules = _compose_modules(_ONE_OR_MANY_PORTS)
plan = compile_all(
_CAM + "\n" + _declared(query).replace("COPY (", _COMPOSE + "\nCOPY (", 1),
describe=lambda path: modules[path],
shape=_compose_shape,
).plan
assert plan is not None
compose = _compose_node(plan)
by_function = {lateral.function: lateral.connections[0].port for lateral in plan.laterals}
assert compose.args["port"] == [by_function["cam"], by_function["play"]]
assert json.loads(compose.bound) == [
{"input": "v", "streams": [{"rate": {"num": 25, "den": 1}}]},
{
"input": "inputs",
"streams": [{"rate": None, "width": 1920, "height": 1080}, {"rate": None}],
},
]


def test_a_port_written_by_hand_stays_one_integer_where_a_list_also_fits() -> None:
query = """COPY (
SELECT compose(s.video[1], port => 9100.0) FROM input('leaf.nut') s
Expand Down
8 changes: 7 additions & 1 deletion docs/dialect.md
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,13 @@ dest := 'path' | STDOUT | ( value-expression ) | sink(value, ...)
conforms it to). An input's is its probe's; a node's picture runs at
its clock, a rate clock's own, the rate of the input a `rate-of`
clock names, or its clock input's over its stride. A feed by port, a
self-clocked node's output and data have none. Each call carries the
self-clocked node's output and data have none. A lateral's picture
whose body reads an input's own first picture with a declared size
(`f.video[1] AS video` over `input(url, shape => STRUCT(1920 AS
width, 1080 AS height, ...)) f`) carries that width and height, and
the sidecar conforms the feed to them; without one, the feed is
conformed to the size of the input its port follows, or the clock
input's. Each call carries the
list it was asked with (`-bound <name>=<json>`), which the host asks
the shape with again, so the shape a node runs with is the one the
query compiled against. A node at a COPY's TO is asked once for its
Expand Down
8 changes: 8 additions & 0 deletions docs/known_gaps.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ from.
of one ends with an upload. Only 8-bit and 10-bit 4:2:0 pictures, and
8-bit 4:4:4 HEVC on CUDA, stay on the card; 4:2:2, 10-bit 4:4:4 and an
input whose pixel format the probe does not report run on the CPU.
- **A lateral's picture size is known only where its body declares it.**
A node input fed on a port by a run-time lateral is conformed to the
size the lateral's body declares (`f.video[1]` of an `input(...)` whose
`shape` gives `width` and `height`). A body that reads a probed file, or
scales its picture, declares none, and its feed is conformed to the size
of the input the port follows, or the clock input's, which for a small
clock is a blur. Declare the `shape` in the body to keep the picture's
own size.
- **The sidecar has no device filters on macOS.** Its bundled
libavfilter carries none for VideoToolbox, so a filter between two
modules holding their pictures on the GPU runs in ffmpeg there, and the
Expand Down
19 changes: 16 additions & 3 deletions sidecar/NODE-CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,15 @@ chains takes its `-bound` flags in turn: the k-th given for the name goes
to the k-th chain calling it. A node source binds nothing and carries
`[]`.

A stream of an input a port serves may also carry `width` and `height`:
the picture size the compile declared for that feed, which the host
conforms the feed to (see "Feeds by port"). The module is not handed
them. The k-th stream of such an input is the one on the k-th port of its
param's list, so the compiler writes an input's streams in the order of
its ports there:

-bound 'compose=[{"input":"v","streams":[{"rate":{"num":60,"den":1}}]},{"input":"inputs","streams":[{"rate":null,"width":1920,"height":1080}]}]'

The list is checked against the pads, and a refusal names the module and
the input:

Expand Down Expand Up @@ -256,9 +265,13 @@ or every output's reader has closed, and a reader closing is a clean end.
## Feeds by port

A hold input the call gives a port rather than a stream is bound a stream
of the host's own at `init`, in the port's format: the size and colour of
the input it follows (`accepts.like`, or the clock input), in the first
pixel or sample format the port accepts. The host listens on the port
of the host's own at `init`, in the port's format. A picture takes the
size `-bound` gives that stream (`width` and `height`, the size the
compile declared for the feed), else the size of the input it follows
(`accepts.like`), else the clock input's; sound takes the rate and layout
of the input it follows or of the clock input. Either is in the first
pixel or sample format the port accepts, else that input's, with that
input's colour where there is one. The host listens on the port
from the moment the run starts, and whatever connects and writes a NUT of
video and PCM is the input's source for as long as it stays: its picture
is conformed to that format on the way in (rgba and yuv420p, at any size;
Expand Down
15 changes: 12 additions & 3 deletions sidecar/NODE-SHAPE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,15 @@ rows or data-filter interface is refused.
stream bound to it, in the order the call names them. `rate` is a video
stream's frame rate or an audio stream's sample rate over 1, `null` (or
absent) where nothing settles it before the run; `streams` absent is
one stream with no rate. Or the comma list of the names alone, each one
one stream with no rate. A stream of an input a port serves may also
give `width` and `height`, both or neither, the size its feed is
conformed to (`NODE-CLI.md`, "Feeds by port"); the module's `shape` is
handed the rate alone. Or the comma list of the names alone, each one
stream with no rate, a name written again one stream more (`v,v,v`
binds three to `v`). Absent or empty binds none. A name the shape does
not declare is refused, and so is an input named twice in the JSON, an
empty `streams`, a rate that is not positive and a key not listed here.
empty `streams`, a rate that is not positive, a width without a height
(or the reverse) or either of them 0, and a key not listed here.
The run is handed the list each call was asked with as `-bound`
(`NODE-CLI.md`, "The shape a call was planned with"), and asks the
shape with it again.
Expand Down Expand Up @@ -334,7 +338,10 @@ video. A module of the node world alone, or of an older world, has
An input held on the GPU that follows another input (`accepts.like`) is
told that input's size at `init`, and the host resizes its frames on the
device as they arrive, with a bilinear filter; an input in host memory
bound to a stream is handed it at the stream's size. A frame reaches a
bound to a stream is handed it at the stream's size. An input fed on a
port is told the size `-bound` declares for its feed, else that of the
input it follows, else the clock input's (`NODE-CLI.md`, "Feeds by
port"), in either memory, and each feed is conformed to that size. A frame reaches a
port in the port's format and size whichever way it arrived: put on the
GPU as it reaches the node, left there by a decoder (`AV-EDGE.md`), or
fed on a port (`NODE-CLI.md`). Each kind of conversion the host makes on
Expand All @@ -361,6 +368,8 @@ frame: its stream, its pts, and its feed's start (`feed-start.at` and
`v` and `out` on the GPU, `{"memory":"host"}` holds neither, and the two
write the same bytes. `{"w":"pad"}` adds a second input, `w`, that
follows `v`, and `{"w":"port","port":N}` feeds it on a port.
`{"w":"own","port":N}` feeds it on a port with no `like`, and `out` takes
`w`'s size.

A module that runs a model on its frames includes `ffrwd:gpu/gpu-nn-module`
instead and asks `ffrwd:gpu/nn.frame-tensor` for a frame as the model's
Expand Down
Loading
Loading