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


222
223
224
225
# File 'lib/animate_it/composition.rb', line 222

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)


311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
# File 'lib/animate_it/composition.rb', line 311

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.



234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/animate_it/composition.rb', line 234

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.



297
298
299
300
301
302
303
304
305
306
# File 'lib/animate_it/composition.rb', line 297

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 ------



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

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

.beatsObject



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

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

.chapter(name, beat:, label:, metadata: {}) ⇒ Object

Public, user-navigable moments. Chapters reference existing beats so animation timing stays the single source of truth.



197
198
199
# File 'lib/animate_it/composition.rb', line 197

def chapter(name, beat:, label:, metadata: {})
  chapters.add(name, beat:, label:, metadata:)
end

.chaptersObject



201
202
203
# File 'lib/animate_it/composition.rb', line 201

def chapters
  @chapters ||= Chapters.new(self)
end

.client_driven!Object

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



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

def client_driven!
  @client_driven = true
end

.client_driven?Boolean

Returns:

  • (Boolean)


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

def client_driven?
  @client_driven == true
end

.duration(value = nil) ⇒ Object



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

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

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

.fps(value = nil) ⇒ Object



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

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

  @fps = value.to_i
end

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



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

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



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

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
21
# 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)
  subclass.instance_variable_set(:@chapters, Chapters.new(subclass))
  super
end

.output_basename(value = nil) ⇒ Object



227
228
229
230
# File 'lib/animate_it/composition.rb', line 227

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

.output_format(value = nil) ⇒ Object



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

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.



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

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

.player_manifestObject



205
206
207
# File 'lib/animate_it/composition.rb', line 205

def player_manifest
  PlayerManifest.new(self)
end

.props(&block) ⇒ Object



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

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.



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

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)


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

def public_player?
  @public_player_options.present?
end

.public_player_optionsObject



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

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

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



352
353
354
355
356
357
358
359
360
361
362
363
364
365
# File 'lib/animate_it/composition.rb', line 352

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



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

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



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

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



256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/animate_it/composition.rb', line 256

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



330
331
332
# File 'lib/animate_it/composition.rb', line 330

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

.size(width, height) ⇒ Object



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

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

.structure_epochs(*frames) ⇒ Object



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

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

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

.structure_layersObject



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

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



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

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

.transition_seriesObject



334
335
336
# File 'lib/animate_it/composition.rb', line 334

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

.transparent?Boolean

Returns:

  • (Boolean)


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

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

.verification_props(*variants) ⇒ Object

Raises:

  • (ArgumentError)


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

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.



249
250
251
252
253
254
# File 'lib/animate_it/composition.rb', line 249

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.



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

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

  @zoom = value.to_f
end