Byg

Kontroller og egenskaber

Hvorfor filmens egenskaber (props) er JSON, hvordan defaultProps og controls lader en vært vise et egenskabspanel, og hvad element-id’er bruges til.

En films input er dens egenskaber, også kaldet props. De er almindelig JSON, har standardværdier og er beskrevet godt nok til, at en vært kan vise et egenskabspanel til dem. Gør du det rigtigt, kan et menneske, der aldrig har åbnet filen, ændre navnet på en navnebjælke, vælge en side og rendere den.

Egenskaber er JSON, og det er en regel

En films props gemmes i projektfiler, flettes ind fra en fil på kommandolinjen (--props ./props.json) og sendes mellem processer — editoren, rendererens skjulte vindue og CLI'et. Hele rejsen foregår gennem JSON.stringify. Derfor skal props kunne overleve den.

packages/core/src/types.ts angiver præcis, hvad det betyder:

export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[];
export type JsonObject = { readonly [key: string]: JsonValue };

defineFilm<Props extends JsonObject> begrænser dine props til dette og kontrollerer det også ved kørsel — både når filmen defineres og igen ved hvert render(). Følgende er udelukket:

  • Funktioner. Ingen render-callbacks, ingen indsendt easing-funktion, ingen formateringsfunktion. En film tager data og bestemmer, hvad den vil gøre med dem.
  • Datoer. En Date overlever stringify som en streng og vender tilbage som en streng, hvilket er en fejl, der blot venter på en tidszone. Send et tal eller en ISO-streng, og fortolk det i filmen.
  • Klasseinstanser. defineFilm afviser objekter, hvis prototype hverken er Object.prototype eller null, fordi klassen ikke følger med.
  • undefined, NaN og Infinity. Kun endelige tal; brug null til »ingenting«.
  • Cirkulære referencer. Afvises med en navngiven fejl i stedet for at hænge.
  • React-elementer. De er objekter med funktioner i. Sammensæt inde i filmen, ikke gennem props.

Det giver en tydelig fejl i stedet for lydløst at kassere en nøgle, og det er pointen: En prop, der forsvandt mellem forhåndsvisning og eksport, er den værste slags fejl at finde ved slutningen af en rendering.

Standardværdier med defaultProps

Hver prop skal have en standardværdi, og det kræver typen — defaultProps: Props, ikke Partial<Props>. Der er to grunde. En film skal kunne renderes helt uden input, fordi et miniaturebillede, en test og en agent, der undersøger den, alle vil gøre netop det. Og render() fletter: { ...defaultProps, ...inputProps }, så en vært kan nøjes med at sende de nøgler, et menneske har ændret.

Lad standardværdierne give en god film. Det er det, man ser, første gang filmen tilføjes, og en standardværdi på "" eller 0 ser defekt ud frem for tom.

Redigeringskontroller med controls

controls er et valgfrit map fra propnavn til en beskrivelse af, hvordan den redigeres. Hele typen:

type FilmControl = Readonly<{
  type: "text" | "number" | "color" | "boolean" | "choice";
  label: string;
  min?: number;
  max?: number;
  step?: number;
  options?: readonly string[];
}>;

Fem typer, ét påkrævet label, og resten gælder, hvor det giver mening:

Type Proptype Ekstra felter Vises som
text string — Et tekstfelt.
number number min, max, step En skyder eller talkontrol med grænser.
color string — En farveprøve og farvevælger.
boolean boolean — En kontakt.
choice string options En menu med options.

Der findes bevidst ingen object- eller array-kontrol. En kontrol er noget, et menneske kan justere; en liste af datapunkter er ikke.

Hver prop bør have en kontrol. Kontrollerne udgør hele forskellen mellem en film, en redaktør kan bruge, og en film, kun dens forfatter kan ændre. En prop uden kontrol er usynlig i egenskabspanelet, så den kan kun ændres ved at redigere kilden eller skrive en props-fil manuelt — fint for en beregnet konstant, men forkert for noget, et menneske ville justere.

describeFilm(film) viderefører controls til den FilmDescriptor, værten modtager. ZXN Studios egenskabspanel læser dette map, tegner én række pr. post i objektets deklarationsrækkefølge og sender et delvist props-objekt tilbage med kun det ændrede. min, max og step afgrænser skyderen, så en film ikke skal forsvare sig mod et billedantal på -3. Intet i core tegner nogen brugergrænseflade: Core beskriver, værten bestemmer.

En navnebjælke, forstået gennem dens egenskaber

examples/recipes/lower-third.tsx er det tilfælde, kontrollerne er skabt til: en navnebjælke, som en producer vil omskrive til hvert interview uden nogensinde at ville åbne en kodeeditor.

export const lowerThirdFilm = defineFilm({
  id: "lower-third",
  title: "Lower third",
  width: 1920,
  height: 1080,
  frameRate: 30,
  frames: 150,
  component: LowerThird,
  defaultProps: { name: "Ada Okafor", role: "Lead Animator", accent: "#2ec4b6", side: "left" },
  controls: {
    name: { type: "text", label: "Name" },
    role: { type: "text", label: "Role" },
    accent: { type: "color", label: "Accent" },
    side: { type: "choice", label: "Side", options: ["left", "right"] },
  },
});

Fire props, fire kontroller, ingen huller.

side er et choice frem for en boolesk værdi kaldet onTheRight, fordi etiketten i egenskabspanelet så viser »Side: venstre / højre« i stedet for at bede nogen regne ud, hvad falsk betyder. Proppens type er string — et choice er altid en streng, og filmen sammenligner den:

  const direction = side === "right" ? 1 : -1;

Baggrunden er gennemsigtig. <FullFrame> sætter her kun layout og indvendig afstand, ingen background, så bjælken kan ligge over videomateriale i en redigering, når den renderes til et format med alfakanal:

    <FullFrame style={{ alignItems: "flex-end", justifyContent: side === "right" ? "flex-end" : "flex-start", padding: "0 120px 130px" }}>

Og hele tekstens forløb — indgang, pause, udgang — er ét mapRange med fire støttepunkter:

  const text = mapRange(frame, [8, 22, last - 20, last - 8], [0, 1, 1, 0], { clamp: true, ease: easing.easeOut });

mapRange tager tilsvarende lister med to eller flere støttepunkter, så fire inputpunkter og fire outputpunkter beskriver stigning, pause og fald på én linje. Tidsforløbet er læselig med et blik, og der er ingen kæde af indlejrede ternære udtryk at lave fejl i. Bemærk, at de sidste to støttepunkter udledes af film.frames i stedet for at være skrevet som faste værdier: Ændr filmens længde, og udgangen lander stadig ved slutningen.

Numeriske kontroller

examples/recipes/elapsed-clock.tsx bevæger et ur fra ét klokkeslæt til et andet. Begge tidspunkter er tal og får derfor grænser:

  controls: {
    from: { type: "number", label: "From (hours)", min: 0, max: 24, step: 0.25 },
    to: { type: "number", label: "To (hours)", min: 0, max: 48, step: 0.25 },
    face: { type: "color", label: "Face" },
    accent: { type: "color", label: "Accent" },
    caption: { type: "text", label: "Caption" },
  },

step: 0.25 afspejler proppens betydning: Timerne har en decimaldel, og et kvarter er den enhed, man faktisk ville vælge. max: 48 på to og 24 på from tillader en bevægelse forbi midnat uden at tillade et starttidspunkt, der ikke findes. Etiketterne angiver »(timer)«, fordi from og to alene kunne være hvad som helst.

Filmen dokumenterer også enheden på typen, hvor den, der læser koden, vil kigge:

  /** 24-hour start and end, as hours with a decimal part: 9.5 is 09:30. */
  readonly from: number;
  readonly to: number;

Element-id'er

To dataattributter markerer de dele af en film, et menneske kan ønske at styre enkeltvis:

  • data-zxn-element-id — et stabilt id, unikt i filmen.
  • data-zxn-name — en læsbar etiket til en liste i værtens brugergrænseflade.
      <div data-zxn-element-id="strap" style={{ display: "flex", alignItems: "stretch", transform: `translateX(${direction * ((1 - slide) * 140 + leave * 140)}px)`, opacity: Math.min(slide * 1.4, 1 - leave) }}>
        <div data-zxn-element-id="rule" style={{ width: 12, background: accent, borderRadius: 6, transform: `scaleY(${slide})`, transformOrigin: "50% 100%" }} />

En vært, der kender disse id'er, kan lade nogen klikke på stillingslinjen i forhåndsvisningen og sætte nøglebilleder for dens opacitet eller flytte bjælken to billeder senere uden at røre andet. Uden dem er filmen ét uigennemsigtigt rektangel.

To ting gør et id nyttigt:

Stabilt. Samme element har samme id på hvert billede og på tværs af renderinger. bar-${name} i søjlediagramræset er knyttet til dataene, ikke en arrayposition, så en søjle, der overhaler en anden, beholder sit id. Et id udledt af et løkkeindeks ville overdrage en persons nøglebilleder til en anden søjle, så snart rækkefølgen ændrede sig.

Meningsfuldt. Sæt dem på de ting, et menneske ville pege på: titlen, bjælken, stregen, en søjle eller en fremhævelse. Omkring et dusin pr. film er den rigtige størrelsesorden.

Sæt dem ikke på partikler. Et regnfelt, et stjernefelt, et støjfelt — alt, der tegnes i en løkke med hundredvis af elementer — får ingen id'er. Ingen vil vælge regndråbe nummer fire hundrede, attributterne koster DOM på hvert billede, og sådan et felt bør under alle omstændigheder være ét <Canvas2D>, hvor der ikke er elementer at mærke. Se Tegn på canvas og Ydeevne.

Et kort med alle fem kontroller

Hver prop her kan justeres, og filmen udleder alt af billednummeret.

import { FullFrame, defineFilm, easing, mapRange, springValue, useTimeline } from "@zxn/motion-core";

/*
 * A stat card: one number, said well.
 *
 * Every prop has a control, so the whole card is editable from an inspector --
 * the wording, the figure, the colour, whether the change chip is shown, and
 * which corner it sits in. Nothing is a constant that only the author can reach.
 */

type Props = {
  readonly label: string;
  readonly value: number;
  readonly unit: string;
  readonly change: number;
  readonly accent: string;
  readonly showChange: boolean;
  readonly corner: string;
};

function StatCard({ label, value, unit, change, accent, showChange, corner }: Props) {
  const { frame, film } = useTimeline();
  const last = film.frames - 1;
  const rise = springValue({ frame, frameRate: film.frameRate, stiffness: 140, damping: 18, clamp: true });
  const leave = mapRange(frame, [last - 14, last], [0, 1], { clamp: true, ease: easing.easeIn });
  // The figure counts up, then holds long enough to be read.
  const count = mapRange(frame, [10, 58], [0, value], { clamp: true, ease: easing.easeOut });
  const chip = mapRange(frame, [52, 68], [0, 1], { clamp: true, ease: easing.easeOut });
  const onRight = corner === "right";

  return (
    <FullFrame style={{ background: "#0f1117", alignItems: "center", justifyContent: onRight ? "flex-end" : "flex-start", padding: "0 160px", fontFamily: "Inter, system-ui, sans-serif" }}>
      <div
        data-zxn-element-id="card"
        data-zxn-name="Stat card"
        style={{
          width: 760, padding: "56px 64px 62px", borderRadius: 32,
          background: "rgba(255,255,255,.04)", border: `2px solid ${accent}`,
          opacity: Math.min(rise, 1 - leave),
          transform: `translateY(${(1 - rise) * 60 + leave * 40}px)`,
        }}
      >
        <div data-zxn-element-id="label" style={{ fontSize: 38, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "rgba(243,239,231,.55)" }}>{label}</div>
        <div data-zxn-element-id="figure" style={{ marginTop: 18, display: "flex", alignItems: "baseline", gap: 14, color: "#f3efe7" }}>
          <span style={{ fontSize: 190, fontWeight: 900, letterSpacing: "-0.05em", lineHeight: 1, fontVariantNumeric: "tabular-nums" }}>{Math.round(count)}</span>
          <span style={{ fontSize: 76, fontWeight: 700, color: accent }}>{unit}</span>
        </div>
        {showChange ? (
          <div
            data-zxn-element-id="change"
            data-zxn-name="Change chip"
            style={{
              marginTop: 26, display: "inline-flex", alignItems: "center", gap: 12,
              padding: "12px 26px", borderRadius: 999, fontSize: 36, fontWeight: 700,
              background: change < 0 ? "rgba(255,111,97,.16)" : "rgba(46,196,182,.16)",
              color: change < 0 ? "#ff6f61" : "#2ec4b6",
              opacity: chip, transform: `translateY(${(1 - chip) * 16}px)`,
            }}
          >
            <span>{change < 0 ? "▼" : "▲"}</span>
            <span style={{ fontVariantNumeric: "tabular-nums" }}>{Math.abs(change)}%</span>
            <span style={{ fontWeight: 500, opacity: 0.75 }}>on last quarter</span>
          </div>
        ) : null}
      </div>
    </FullFrame>
  );
}

export const films = [defineFilm({
  id: "stat-card",
  title: "Stat card",
  width: 1920,
  height: 1080,
  frameRate: 30,
  frames: 120,
  component: StatCard,
  defaultProps: { label: "Renders this month", value: 1284, unit: "clips", change: 23, accent: "#7a6cff", showChange: true, corner: "left" },
  controls: {
    label: { type: "text", label: "Label" },
    value: { type: "number", label: "Value", min: 0, max: 100000, step: 1 },
    unit: { type: "text", label: "Unit" },
    change: { type: "number", label: "Change (%)", min: -100, max: 100, step: 1 },
    accent: { type: "color", label: "Accent" },
    showChange: { type: "boolean", label: "Show change" },
    corner: { type: "choice", label: "Corner", options: ["left", "right"] },
  },
})];

Syv props, syv kontroller. change har fortegn, og filmen vælger både pil og farve ud fra det, så ét tal rummer hele idéen; en separat boolesk isNegative ville være en anden sandhedskilde, der skulle holdes i takt. showChange er boolesk, fordi den faktisk har to tilstande; corner er et valg, fordi »venstre eller højre« har navne.

Egenskaber på kommandolinjen

CLI'et fletter en JSON-fil oven på standardværdierne, så samme film renderer hundrede kort fra hundrede filer:

zxn-motion render ./films.tsx stat-card ./out/q3.mp4 --props ./q3.json
{ "label": "Renders in Q3", "value": 4102, "change": -6, "corner": "right" }

Kun de nøgler, du sender, tilsidesættes. Filen skal være et JSON-objekt, og en værdi, der ikke er JSON, giver en fejl frem for en automatisk typekonvertering.

Næste skridt