Tempo og musik
Klip en film til et musiknummer: Angiv et tempo, placer sekvenser i takter og slag, og udled pulsslag og underdelinger af ét musikalsk ur.
En film, der klippes til musik, skrives i takter og slag, men alt derefter — sekvenser, afspillerens tidslinjeskyder og eksporten — tæller billeder. Et deklareret tempo giver filmen et musikalsk ur, så den kan skrives, som den høres, og lader én funktion håndtere alle konverteringer.
Angiv et tempo
tempo står i filmdefinitionen ved siden af størrelse og billedfrekvens.
const tempo = { bpm: 120, beatsPerBar: 4, offsetSeconds: 0 };
bpm er påkrævet og skal ligge mellem 1 og 1000. beatsPerBar er som standard 4 og skal være et positivt heltal. offsetSeconds er som standard 0 og angiver, hvor takt nul ligger i filmen, så et musiknummer med optakt, indtælling eller et øjebliks stilhed før nedslaget stadig passer. Både takter og slag tælles fra nul: Takt 0, slag 0 er første nedslag.
Et ugyldigt tempo kaster en fejl, når filmen defineres, ikke halvvejs gennem en rendering.
Uret
useTempo() returnerer filmens ur. Den kaster en fejl, hvis filmen ikke angiver et tempo, fordi en sekvens placeret på en takt i en film uden takter er en fejl, der bør vises, frem for lydløst at give nul.
| Medlem | Returnerer |
|---|---|
tempo |
Det { bpm, beatsPerBar?, offsetSeconds? }, filmen har angivet. |
beatsPerBar |
Den afklarede værdi, 4 hvis filmen ikke angav noget. |
bar(n) |
Det billede, takt n starter på. |
beat(n) |
Det billede, slag n starter på. Brøkdele af slag er tilladt, så beat(0.5) er ottendedelen efter nedslaget. |
barAt(frame) |
Den takt inklusive brøkdel, som et billede falder på. Negativ før offsetSeconds. |
beatAt(frame) |
Det slag inklusive brøkdel, som et billede falder på. Negativ før offsetSeconds. |
onBeat(frame, division?) |
Om et slag eller en underdeling af et slag lander på præcis dette billede. division er 1 for hvert slag, 2 for ottendedele, 4 for sekstendedele; værdien skal være et positivt heltal. |
Samme matematik eksporteres som almindelige funktioner til kode uden for en komponent: barFrame, beatFrame, barAtFrame, beatAtFrame, isOnBeat, secondsPerBeat, resolveMusicalTiming og createTempoClock.
import { beatFrame, createTempoClock, secondsPerBeat } from "@zxn/motion-core";
const clock = createTempoClock({ bpm: 120 }, 30);
clock.bar(2); // 120
clock.beat(0.5); // 8, the first eighth note
beatFrame({ bpm: 120 }, 30, 4); // 60
secondsPerBeat({ bpm: 96 }); // 0.625
Sekvenser på gitteret
<Cue> tager både musikalsk tidsstyring og billeder: startBar, startBeat, lengthBars og lengthBeats. Takter og slag lægges sammen, så startBar={2} startBeat={1} er andet slag i takt to.
<Cue startBar={2} startBeat={1} lengthBeats={2}>
<Flash />
</Cue>
En længde angivet i takter eller slag måles fra den afklarede start i stedet for at blive lagt til som et fast antal billeder, så en sekvens på to takter dækker præcis to virkelige takter, uanset hvor den starter. Ved en billedfrekvens, der ikke deler et slag jævnt, er det ikke samme antal billeder, og et fast antal ville forskyde sig i forhold til musikken undervejs i filmen. Inde i en sekvens genstarter frame ved nul, præcis som i en sekvens placeret med billeder.
Musikalsk tidsstyring i en film uden tempo kaster en fejl med en forklaring. Det samme gælder en sekvens, hvis musikalske længde bliver under ét billede.
Én konvertering, én afrunding
Hver takt og hvert slag i systemet omsættes gennem beatFrame. Det er det eneste sted, hvor musikalsk tid bliver til et heltalligt billednummer, og derfor lander et klip og et blink, der er skrevet til samme nedslag, på samme billede frem for med ét billedes mellemrum. Konvertering hos hver kalder er vejen til en film, hvor titlen rammer slaget, og understregningen kommer lige efter — den slags fejl, der er usynlig i et stillbillede og tydelig i bevægelse.
Hvert slag beregnes ud fra sit eget indeks i stedet for ved at lægge et slags antal billeder til det foregående. Akkumulering afrunder én gang pr. slag, og fejlen vokser: Ved 29,97 fps varer et slag på 120 bpm 14,985 billeder, så afrunding til 15 hver gang tilføjer en hundrededel billede pr. slag, og ved takt 200 er billedet hele billeder foran musikken. Beregning fra indekset afrunder én gang, og testene holder hvert slag gennem 200 takter inden for et halvt billede ved 30, 29,97 og 24 fps.
onBeat er sand på præcis ét billede pr. underdeling: Den finder den underdeling, billednummeret ligger nærmest, konverterer tilbage gennem beatFrame og sammenligner. Et blink, den udløser, varer ét billede i stedet for at flimre over to, og intet slag overses, fordi det ønskede billede faldt mellem to underdelinger. Før tempoets forskydning er værdien altid falsk — der findes ikke et slag minus én.
Gennemgang af opskriften på en slagpuls
examples/recipes/beat-pulse.tsx er den mindste film, der bruger alt dette: Fire ord, der skifter på takter, en ring, der pulserer på hvert slag, og fire prikker, der viser, hvor i takten du er.
Ordene er fire sekvenser på én takt hver. Intet i dem er et billednummer, så ændring af bpm flytter alle klip.
{words.map((word, index) => (
<Cue key={word} startBar={index} lengthBars={1}>
<FullFrame style={{ alignItems: "center", justifyContent: "center" }}>
<div style={{ font: "900 260px Inter, system-ui, sans-serif", color: "#fff", letterSpacing: "-0.05em", transform: `scale(${1 + 0.1 * kick})` }}>{word}</div>
</FullFrame>
</Cue>
))}
Pulsen er ikke onBeat. En boolesk værdi på ét billede passer til et blink, men en puls skal aftage over flere billeder, så filmen spørger, hvor langt den er forbi det seneste slag, og omsætter det til en faldende værdi.
// How far through the current beat we are: 0 on the beat, 1 just before the next.
const beat = tempo.beatAt(frame);
const sinceBeat = frame - tempo.beat(Math.floor(beat));
const kick = mapRange(sinceBeat, [0, 7], [1, 0], { clamp: true, ease: easing.easeOut });
beatAt giver slaget inklusive brøkdel; afrunding nedad angiver det senest påbegyndte slag; beat() omsætter indekset tilbage til det billede, det landede på. Subtraktionen er derfor i faktiske billeder, og pulsen aftager over de samme syv billeder ved ethvert tempo. Den ene kick-værdi styrer både den voksende ring og ordenes skalering, så de ånder sammen.
Prikkerne er positionen i takten aflæst direkte fra uret:
{Array.from({ length: 4 }, (_, dot) => (
<div key={dot} style={{ width: 30, height: 30, borderRadius: "50%", background: "#fff", opacity: Math.floor(beat) % 4 === dot ? 1 : 0.28 }} />
))}
Og selve tempoet, med filmens længde skrevet i takter frem for gættet:
// Four bars of four beats at 120 bpm is eight seconds.
frames: 30 * 8,
tempo: { bpm: 120, beatsPerBar: 4 },
En sequencer på ottendedele
onBeat med en division styrer noget hurtigere end pulsslaget. Denne film kører et gitter med otte trin og en equalizer med tolv bånd på ottendedele: Felterne lyser på skift, båndene hopper til en ny højde med fast startværdi på hver ottendedel og falder tilbage, og hele billedet lysnes i ét billede på hvert nedslag.
import { FullFrame, defineFilm, easing, mapRange, seededRandom, useTempo, useTimeline } from "@zxn/motion-core";
type Props = {
readonly label: string;
readonly accent: string;
readonly background: string;
};
const steps = 8;
const bands = 12;
const barHeight = 360;
function Sequencer({ label, accent, background }: Props) {
const { frame, film } = useTimeline();
const tempo = useTempo();
// Which eighth note this frame belongs to, and how far past its start it is.
const eighth = Math.max(0, Math.floor(tempo.beatAt(frame) * 2));
const step = eighth % steps;
const sinceEighth = frame - tempo.beat(eighth / 2);
const attack = mapRange(sinceEighth, [0, 8], [1, 0], { clamp: true, ease: easing.easeOut });
// A one-frame lift on the beat, and a brighter one on the downbeat.
const pulse = tempo.onBeat(frame) ? 1 : 0;
const downbeat = tempo.onBeat(frame) && step === 0 ? 1 : 0;
const out = mapRange(frame, [film.frames - 14, film.frames - 1], [1, 0], { clamp: true, ease: easing.easeIn });
return (
<FullFrame style={{ background, flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 70, opacity: out }}>
<div style={{ position: "absolute", inset: 0, background: accent, opacity: 0.05 * pulse + 0.1 * downbeat }} />
<div style={{ font: "800 40px Inter, system-ui, sans-serif", color: "#f3efe7", letterSpacing: "0.36em", opacity: 0.55 + 0.35 * attack }}>
{label}
</div>
<div style={{ display: "flex", alignItems: "flex-end", gap: 16, height: barHeight }}>
{Array.from({ length: bands }, (_value, band) => {
// A new height per band per eighth, seeded so it is the same every render.
const target = 0.2 + seededRandom(`band-${String(band)}-${String(eighth)}`) * 0.8;
const height = barHeight * (0.12 + (target - 0.12) * attack);
return (
<div
key={band}
style={{ width: 56, height, borderRadius: 12, background: accent, opacity: 0.45 + 0.55 * (height / barHeight) }}
/>
);
})}
</div>
<div style={{ display: "flex", gap: 20 }}>
{Array.from({ length: steps }, (_value, pad) => {
const live = pad === step;
return (
<div
key={pad}
style={{
width: 86,
height: 86,
borderRadius: 20,
border: `3px solid ${accent}`,
background: live ? accent : "transparent",
opacity: live ? 0.4 + 0.6 * attack : 0.3,
transform: `scale(${String(live ? 1 + 0.08 * attack : 1)})`,
}}
/>
);
})}
</div>
</FullFrame>
);
}
export const films = [defineFilm({
id: "sequencer",
title: "Sequencer",
width: 1920,
height: 1080,
frameRate: 30,
// Eight bars of four beats at 128 bpm, rounded up to a whole second.
frames: 450,
tempo: { bpm: 128, beatsPerBar: 4 },
component: Sequencer,
defaultProps: { label: "EIGHT ON THE FLOOR", accent: "#7a6cff", background: "#0b0c0e" },
controls: {
label: { type: "text", label: "Label" },
accent: { type: "color", label: "Accent" },
background: { type: "color", label: "Background" },
},
})];
Tre ting er værd at fremhæve. Trinindekset kommer fra beatAt(frame) * 2 — slaget inklusive brøkdel skaleret med underdelingen — så gitteret og uret ikke kan være uenige. Aftagningen kommer fra frame - tempo.beat(eighth / 2) og bruger et slagindeks med brøkdel, fordi beat() accepterer det. Og højderne kommer fra seededRandom med både båndet og ottendedelen som nøgle, hvilket gør dem vilkårlige, men faste: Træk tilbage til billede 120, og søjlerne står præcis, hvor de stod før.
Arbejd med rigtig musik
En film indeholder ingen repræsentation af lyden. Lyd ligger uden for rammen her; lyd hører hjemme på værtens tidslinje ved siden af det klip, filmen renderes til. Filmens tempo aflæses derfor ikke fra musiknummeret; det angives om det, og de to stemmer kun overens, fordi du har sørget for det.
Match to tal. bpm er musiknummerets tempo: Find det i filens metadata, din DAW, butikssiden, hvor du købte det, eller tæl slag i tredive sekunder og gang med to. offsetSeconds er der, hvor første nedslag i takt nul falder i den færdige redigering — stilheden før musiknummeret starter plus en eventuel optakt før første nedslag. Er bpm lidt forkert, glider filmen ud af takt over sin længde; er offsetSeconds forkert, er afvigelsen konstant, hvilket er lettere at høre og rette.
Vælg om muligt en billedfrekvens, hvor et slag er et helt antal billeder. Ved 120 bpm er et slag et halvt sekund: 15 billeder ved 30 fps og 12 ved 24. Så lander hvert slag på et præcist billede, og intet skal afrundes.
Når det ikke går op — 128 bpm ved 30 fps giver 14,0625 billeder pr. slag — går intet i stykker. Slagene lander på nærmeste billede, op til et halvt billede, cirka 17 millisekunder, fra musikkens placering, og afstanden mellem på hinanden følgende slag skifter mellem 14 og 15 billeder. Det er under grænsen for, hvornår et klip opleves som forsinket, og fordi hvert slag beregnes ud fra sit eget indeks, akkumuleres fejlen aldrig. Du bør ikke selv afrunde og derefter tælle i billeder.
Arbejder du med billedfrekvenser til tv, så bemærk, at 29,97 fps er 30000 / 1001, ikke 30. Angiv den virkelige værdi som filmens frameRate, så følger uret den.
Næste skridt
- Kompositions-API for den fulde reference.
- Tænk i billeder om, hvorfor intet her aflæser et ur.
- Opskrifter for resten af eksemplerne.
- Film om brugergrænseflader, hvis det, du klipper til musik, er en produktgennemgang.