Class: AnimateIt::Composition

Inherits:
Object
  • Object
show all
Defined in:
lib/animate_it/composition.rb

Defined Under Namespace

Classes: SeriesBuilder, TransitionSeriesBuilder

Constant Summary collapse

SUPPORTED_OUTPUT_FORMATS =
%i[webm mp4 mov gif png_sequence png].freeze

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.duration_in_framesObject (readonly)

Returns the value of attribute duration_in_frames.



6
7
8
# File 'lib/animate_it/composition.rb', line 6

def duration_in_frames
  @duration_in_frames
end

.heightObject (readonly)

Returns the value of attribute height.



6
7
8
# File 'lib/animate_it/composition.rb', line 6

def height
  @height
end

.props_schemaObject (readonly)

Returns the value of attribute props_schema.



6
7
8
# File 'lib/animate_it/composition.rb', line 6

def props_schema
  @props_schema
end

.timelineObject (readonly)

Returns the value of attribute timeline.



6
7
8
# File 'lib/animate_it/composition.rb', line 6

def timeline
  @timeline
end

.widthObject (readonly)

Returns the value of attribute width.



6
7
8
# File 'lib/animate_it/composition.rb', line 6

def width
  @width
end

Class Method Details

.assets_dir(value = nil) ⇒ Object

----- Outputs path helpers -------------------------------------- Declare a directory + basename so outputs do ... end entries can be specified by format alone:

assets_dir "app/assets/images/pages/product"
output_basename "screener-hero"

outputs do
mp4
webm
gif
png frame: 0       # → screener-hero-first.png
end


207
208
209
210
# File 'lib/animate_it/composition.rb', line 207

def assets_dir(value = nil)
  @assets_dir = value.to_s if value
  @assets_dir
end

.audio(path, duration: nil, from: 0, start_at: nil, name: nil, track: :audio, gain: 1.0, loop: false) ⇒ Object

Place an audio segment on its own track. The renderer doesn't draw audio; the studio plays it via

Raises:

  • (ArgumentError)


296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
# File 'lib/animate_it/composition.rb', line 296

def audio(path, duration: nil, from: 0, start_at: nil, name: nil, track: :audio, gain: 1.0, loop: false)
  begin
    gain = Float(gain)
  rescue TypeError, ArgumentError
    raise ArgumentError, "Audio gain must be a number between 0.0 and 1.0"
  end
  raise ArgumentError, "Audio gain must be between 0.0 and 1.0" unless gain.between?(0.0, 1.0)

  timeline.add_segment(
    name: name || File.basename(path.to_s),
    from_frame: Units.frames(start_at || from, fps:),
    duration_frames: Units.frames(duration, fps:),
    kind: :audio,
    track:,
    source: { path: path.to_s, gain:, loop: },
    show_in_timeline: true
  )
end

.audio_loop(path, duration: nil, from: 0, name: nil, gain: 1.0, track: :background) ⇒ Object

----- Audio DSL -------------------------------------------------- Loop a track for the full duration (or a slice). Background music.



219
220
221
222
223
224
225
226
227
228
229
230
# File 'lib/animate_it/composition.rb', line 219

def audio_loop(path, duration: nil, from: 0, name: nil, gain: 1.0, track: :background)
  length = duration || (@duration_in_frames - Units.frames(from, fps: fps))
  audio(
    path,
    duration: length,
    from:,
    name: name || "loop-#{File.basename(path.to_s)}",
    track:,
    gain:,
    loop: true
  )
end

.auto_mount_single_scene!Object

Sweep nested AnimateIt::Scene subclasses and auto-mount the single scene if no explicit scene / series was declared. Called by the registrar after composition file load.



282
283
284
285
286
287
288
289
290
291
# File 'lib/animate_it/composition.rb', line 282

def auto_mount_single_scene!
  return if timeline.segments.any?

  nested = constants.map { |c| const_get(c) }.select do |const|
    const.is_a?(Class) && const < AnimateIt::Scene
  end
  return unless nested.size == 1

  scene(nested.first)
end

.beat(name, at:, length:) ⇒ Object

----- Beats: named time markers on the composition timeline ------



186
187
188
# File 'lib/animate_it/composition.rb', line 186

def beat(name, at:, length:)
  beats.add(name, at: at, length: length)
end

.beatsObject



190
191
192
# File 'lib/animate_it/composition.rb', line 190

def beats
  @beats ||= Beats.new(fps: fps)
end

.client_driven!Object

Opt in to the seekable browser runtime. Legacy compositions continue rendering individual frames on the server.



72
73
74
# File 'lib/animate_it/composition.rb', line 72

def client_driven!
  @client_driven = true
end

.client_driven?Boolean

Returns:

  • (Boolean)


76
77
78
# File 'lib/animate_it/composition.rb', line 76

def client_driven?
  @client_driven == true
end

.duration(value = nil) ⇒ Object



174
175
176
177
178
# File 'lib/animate_it/composition.rb', line 174

def duration(value = nil)
  return @duration_in_frames if value.nil?

  @duration_in_frames = Units.frames(value, fps:)
end

.fps(value = nil) ⇒ Object



145
146
147
148
149
# File 'lib/animate_it/composition.rb', line 145

def fps(value = nil)
  return @fps if value.nil?

  @fps = value.to_i
end

.frame_context(frame:, props:, segment: nil) ⇒ Object



323
324
325
326
327
328
329
330
331
332
333
334
335
# File 'lib/animate_it/composition.rb', line 323

def frame_context(frame:, props:, segment: nil)
  FrameContext.new(
    self,
    props_schema.resolve(props),
    frame.to_i,
    segment ? segment.local_frame(frame.to_i) : frame.to_i,
    fps,
    duration_in_frames,
    width,
    height,
    segment
  )
end

.id(value = nil) ⇒ Object



63
64
65
66
67
68
# File 'lib/animate_it/composition.rb', line 63

def id(value = nil)
  return @id if value.nil?

  @id = value.to_s
  AnimateIt.register(self)
end

.inherited(subclass) ⇒ Object



8
9
10
11
12
13
14
15
16
17
18
19
20
# File 'lib/animate_it/composition.rb', line 8

def inherited(subclass)
  subclass.instance_variable_set(:@timeline, Timeline.new)
  subclass.instance_variable_set(:@props_schema, PropsSchema.new)
  subclass.instance_variable_set(:@fps, 30)
  subclass.instance_variable_set(:@width, 1920)
  subclass.instance_variable_set(:@height, 1080)
  subclass.instance_variable_set(:@zoom, 1.0)
  subclass.instance_variable_set(:@duration_in_frames, 30)
  subclass.instance_variable_set(:@output_format, :webm)
  subclass.instance_variable_set(:@verification_props, [{}].freeze)
  subclass.instance_variable_set(:@public_player_options, nil)
  super
end

.output_basename(value = nil) ⇒ Object



212
213
214
215
# File 'lib/animate_it/composition.rb', line 212

def output_basename(value = nil)
  @output_basename = value.to_s if value
  @output_basename || @id
end

.output_format(value = nil) ⇒ Object



22
23
24
25
26
27
28
29
30
31
# File 'lib/animate_it/composition.rb', line 22

def output_format(value = nil)
  return @output_format if value.nil?

  unless SUPPORTED_OUTPUT_FORMATS.include?(value)
    raise ArgumentError,
          "Unsupported output_format #{value.inspect} — pick :webm, :mp4, :mov, :gif, :png_sequence, or :png"
  end

  @output_format = value
end

.outputs(&block) ⇒ Object

Declarative outputs: list every (format, path) target this composition should produce. Without a block, returns the registered outputs (empty array if none have been declared).

outputs do
mp4 to: "app/assets/images/pages/product/screener-hero.mp4"
gif to: "app/assets/images/pages/product/screener-hero.gif"
png to: "app/assets/images/pages/product/screener-hero-first.png", frame: 0
end

Paths are repo-relative; the renderer joins them onto Rails.root.



48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/animate_it/composition.rb', line 48

def outputs(&block)
  @outputs ||= []
  return @outputs unless block

  builder = OutputsBuilder.new(assets_dir: assets_dir, basename: output_basename)
  builder.instance_eval(&block)
  @outputs = builder.outputs.freeze
  # Keep `output_format` in sync with the first animated output so single-
  # output legacy callers (bin/render_animate_it_video, AnimateIt Studio)
  # see a sensible format without re-declaring it.
  primary = @outputs.find { |o| o.frame.nil? }
  @output_format = primary.format if primary
  @outputs
end

.props(&block) ⇒ Object



180
181
182
183
# File 'lib/animate_it/composition.rb', line 180

def props(&block)
  props_schema.instance_eval(&block) if block
  props_schema
end

.public_player!(autoplay: false, loop: true) ⇒ Object

Explicitly expose this composition through the production-safe public player endpoint. Studio, frame, filmstrip, props, and render endpoints remain local-only. Public playback always uses schema-default props.



83
84
85
86
# File 'lib/animate_it/composition.rb', line 83

def public_player!(autoplay: false, loop: true)
  client_driven!
  @public_player_options = { autoplay: autoplay == true, loop: loop == true }.freeze
end

.public_player?Boolean

Returns:

  • (Boolean)


88
89
90
# File 'lib/animate_it/composition.rb', line 88

def public_player?
  @public_player_options.present?
end

.public_player_optionsObject



92
93
94
# File 'lib/animate_it/composition.rb', line 92

def public_player_options
  @public_player_options || { autoplay: false, loop: true }.freeze
end

.render_frame(view_context, frame:, props: {}, segment_origins: false) ⇒ Object



337
338
339
340
341
342
343
344
345
346
347
348
349
350
# File 'lib/animate_it/composition.rb', line 337

def render_frame(view_context, frame:, props: {}, segment_origins: false)
  resolved_props = props_schema.resolve(props)
  segments = timeline.active_segments(frame.to_i, kind: :scene)
  return "" if segments.empty?

  rendered_segments = segments.map do |segment|
    context = frame_context(frame:, props: resolved_props, segment:)
    content = render_segment(view_context, segment, context)
    next content unless segment_origins

    view_context.tag.div(content, data: { animate_it_segment_origin: segment.from_frame })
  end
  view_context.safe_join(rendered_segments)
end

.render_structure(view_context, props: {}) ⇒ Object



128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/animate_it/composition.rb', line 128

def render_structure(view_context, props: {})
  resolved_props = props_schema.resolve(props)
  rendered_layers = structure_layers.map do |layer|
    context = frame_context(frame: layer.from_frame, props: resolved_props, segment: layer.segment)
    view_context.tag.div(
      render_segment(view_context, layer.segment, context),
      class: "animate-it-layer",
      data: { animate_layer: layer.key }
    )
  end
  view_context.safe_join(rendered_layers)
end

.scene(scene_class, duration: nil, from: 0, start_at: nil, name: nil) ⇒ Object



268
269
270
271
272
273
274
275
276
277
# File 'lib/animate_it/composition.rb', line 268

def scene(scene_class, duration: nil, from: 0, start_at: nil, name: nil, **)
  # Tell the scene class which composition it belongs to so its
  # animation property procs can resolve symbolic beat names against
  # the composition's beat registry.
  scene_class.composition_class = self if scene_class.respond_to?(:composition_class=)
  # If duration isn't given, default to the full composition duration
  # (single-scene shorthand support).
  duration ||= @duration_in_frames
  sequence(from:, start_at:, duration:, name:, scene: scene_class, **)
end

.sequence(from: 0, start_at: nil, duration: nil, name: nil, scene: nil, layout: :none, class_name: nil, style: nil, show_in_timeline: true, track: :main, &block) ⇒ Object



241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/animate_it/composition.rb', line 241

def sequence(
  from: 0,
  start_at: nil,
  duration: nil,
  name: nil,
  scene: nil,
  layout: :none,
  class_name: nil,
  style: nil,
  show_in_timeline: true,
  track: :main,
  &block
)
  timeline.add_segment(
    name: name || scene&.name || "sequence-#{timeline.segments.size + 1}",
    from_frame: Units.frames(start_at || from, fps:),
    duration_frames: Units.frames(duration, fps:),
    scene_class: scene,
    renderer: block,
    layout:,
    class_name:,
    style:,
    show_in_timeline:,
    track:
  )
end

.seriesObject



315
316
317
# File 'lib/animate_it/composition.rb', line 315

def series(&)
  SeriesBuilder.new(self).instance_eval(&)
end

.size(width, height) ⇒ Object



151
152
153
154
# File 'lib/animate_it/composition.rb', line 151

def size(width, height)
  @width = width.to_i
  @height = height.to_i
end

.structure_epochs(*frames) ⇒ Object



104
105
106
107
108
# File 'lib/animate_it/composition.rb', line 104

def structure_epochs(*frames)
  return @structure_epochs || [] if frames.empty?

  @structure_epochs = frames.map(&:to_i).sort.uniq
end

.structure_layersObject



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/animate_it/composition.rb', line 110

def structure_layers
  timeline.segments.each_with_index.flat_map do |segment, segment_index|
    next [] unless segment.kind == :scene

    segment_end = segment.duration_frames ? segment.from_frame + segment.duration_frames : duration_in_frames
    epochs = structure_epochs.select { |frame| frame > segment.from_frame && frame < segment_end }
    bounds = ([segment.from_frame] + epochs).sort.uniq
    bounds.each_with_index.map do |from_frame, index|
      Tracks::Layer.new(
        segment:,
        segment_index:,
        from_frame:,
        to_frame: bounds[index + 1] || segment_end
      )
    end
  end
end

.track_document(props: {}) ⇒ Object



141
142
143
# File 'lib/animate_it/composition.rb', line 141

def track_document(props: {})
  Tracks::Recorder.new(self, props:).call
end

.transition_seriesObject



319
320
321
# File 'lib/animate_it/composition.rb', line 319

def transition_series(&)
  TransitionSeriesBuilder.new(self).instance_eval(&)
end

.transparent?Boolean

Returns:

  • (Boolean)


33
34
35
# File 'lib/animate_it/composition.rb', line 33

def transparent?
  %i[webm mov gif png_sequence png].include?(output_format)
end

.verification_props(*variants) ⇒ Object

Raises:

  • (ArgumentError)


96
97
98
99
100
101
102
# File 'lib/animate_it/composition.rb', line 96

def verification_props(*variants)
  return @verification_props if variants.empty?

  raise ArgumentError, "verification_props entries must be hashes" unless variants.all?(Hash)

  @verification_props = variants.map(&:deep_symbolize_keys).freeze
end

.voice_over(path, at:, duration: nil, name: nil, gain: 1.0, track: :voice) ⇒ Object

Play a voice-over clip at a named beat (or time/frame). The clip's duration defaults to the beat's length.



234
235
236
237
238
239
# File 'lib/animate_it/composition.rb', line 234

def voice_over(path, at:, duration: nil, name: nil, gain: 1.0, track: :voice)
  beat = at.is_a?(Symbol) ? beats.fetch(at) : nil
  from = beat ? beat.start_frame : at
  length = duration || (beat ? beat.duration_frames : 1.second)
  audio(path, duration: length, from: from, name: name || File.basename(path.to_s), track: track, gain: gain)
end

.zoom(value = nil) ⇒ Object

Browser-side magnification applied via CSS zoom on <body> in the frame layout. Multiplies every visual length (font sizes, padding, widths) without changing the output viewport, so heros built against px-sized host partials can lift up the apparent scale without rewriting CSS. Default 1.0 (no zoom).

size 1080, 1170
zoom 1.1

The viewport stays at size(...); only the rendered content inside <body> is magnified. Combine zoom with a smaller size to "zoom in" the apparent scale of the composition at the same aspect ratio.



168
169
170
171
172
# File 'lib/animate_it/composition.rb', line 168

def zoom(value = nil)
  return @zoom if value.nil?

  @zoom = value.to_f
end