Menubar.Render.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841
  1. import * as THREE from 'three';
  2. import { UIPanel, UIRow, UIButton, UIInteger, UISelect, UIText } from './libs/ui.js';
  3. import { ViewportPathtracer } from './Viewport.Pathtracer.js';
  4. import { APP } from './libs/app.js';
  5. function MenubarRender( editor ) {
  6. const strings = editor.strings;
  7. const container = new UIPanel();
  8. container.setClass( 'menu' );
  9. const title = new UIPanel();
  10. title.setClass( 'title' );
  11. title.setTextContent( strings.getKey( 'menubar/render' ) );
  12. container.add( title );
  13. const options = new UIPanel();
  14. options.setClass( 'options' );
  15. container.add( options );
  16. // Image
  17. let option = new UIRow();
  18. option.setClass( 'option' );
  19. option.setTextContent( strings.getKey( 'menubar/render/image' ) );
  20. option.onClick( function () {
  21. showImageDialog();
  22. } );
  23. options.add( option );
  24. // Video
  25. if ( 'VideoEncoder' in window ) {
  26. option = new UIRow();
  27. option.setClass( 'option' );
  28. option.setTextContent( strings.getKey( 'menubar/render/video' ) );
  29. option.onClick( function () {
  30. showVideoDialog();
  31. } );
  32. options.add( option );
  33. }
  34. // Image Dialog
  35. function showImageDialog() {
  36. const dialog = new RenderImageDialog( editor, strings );
  37. document.body.appendChild( dialog.dom );
  38. }
  39. // Video Dialog
  40. function showVideoDialog() {
  41. const dialog = new RenderVideoDialog( editor, strings );
  42. document.body.appendChild( dialog.dom );
  43. }
  44. return container;
  45. }
  46. class RenderImageDialog {
  47. constructor( editor, strings ) {
  48. const dom = document.createElement( 'div' );
  49. dom.className = 'Dialog';
  50. this.dom = dom;
  51. const background = document.createElement( 'div' );
  52. background.className = 'Dialog-background';
  53. background.addEventListener( 'click', () => this.close() );
  54. dom.appendChild( background );
  55. const content = document.createElement( 'div' );
  56. content.className = 'Dialog-content';
  57. dom.appendChild( content );
  58. // Title
  59. const titleBar = document.createElement( 'div' );
  60. titleBar.className = 'Dialog-title';
  61. titleBar.textContent = strings.getKey( 'menubar/render' ) + ' ' + strings.getKey( 'menubar/render/image' );
  62. content.appendChild( titleBar );
  63. // Body
  64. const body = document.createElement( 'div' );
  65. body.className = 'Dialog-body';
  66. content.appendChild( body );
  67. // Shading
  68. const shadingRow = new UIRow();
  69. body.appendChild( shadingRow.dom );
  70. shadingRow.add( new UIText( strings.getKey( 'sidebar/project/shading' ) ).setClass( 'Label' ) );
  71. const shadingTypeSelect = new UISelect().setOptions( {
  72. 'solid': 'SOLID',
  73. 'realistic': 'REALISTIC'
  74. } ).setWidth( '170px' ).onChange( refreshShadingRow ).setValue( 'solid' );
  75. shadingRow.add( shadingTypeSelect );
  76. const pathTracerMinSamples = 3;
  77. const pathTracerMaxSamples = 65536;
  78. const samplesNumber = new UIInteger( 16 ).setRange( pathTracerMinSamples, pathTracerMaxSamples );
  79. const samplesRow = new UIRow();
  80. samplesRow.add( new UIText( strings.getKey( 'sidebar/project/image/samples' ) ).setClass( 'Label' ) );
  81. samplesRow.add( samplesNumber );
  82. body.appendChild( samplesRow.dom );
  83. function refreshShadingRow() {
  84. samplesRow.setHidden( shadingTypeSelect.getValue() !== 'realistic' );
  85. }
  86. refreshShadingRow();
  87. // Resolution
  88. const resolutionRow = new UIRow();
  89. body.appendChild( resolutionRow.dom );
  90. resolutionRow.add( new UIText( strings.getKey( 'sidebar/project/resolution' ) ).setClass( 'Label' ) );
  91. const imageWidth = new UIInteger( 1024 ).setTextAlign( 'center' ).setWidth( '28px' );
  92. resolutionRow.add( imageWidth );
  93. resolutionRow.add( new UIText( '\u00D7' ).setTextAlign( 'center' ).setFontSize( '12px' ).setWidth( '12px' ) );
  94. const imageHeight = new UIInteger( 1024 ).setTextAlign( 'center' ).setWidth( '28px' );
  95. resolutionRow.add( imageHeight );
  96. // Buttons
  97. const buttonsRow = document.createElement( 'div' );
  98. buttonsRow.className = 'Dialog-buttons';
  99. body.appendChild( buttonsRow );
  100. const renderButton = new UIButton( strings.getKey( 'sidebar/project/render' ) );
  101. renderButton.setWidth( '80px' );
  102. renderButton.onClick( async () => {
  103. if ( shadingTypeSelect.getValue() === 'realistic' ) {
  104. let isMaterialsValid = true;
  105. editor.scene.traverseVisible( ( object ) => {
  106. if ( object.isMesh ) {
  107. const materials = Array.isArray( object.material ) ? object.material : [ object.material ];
  108. for ( let i = 0; i < materials.length; i ++ ) {
  109. const material = materials[ i ];
  110. if ( ! material.isMeshStandardMaterial ) {
  111. isMaterialsValid = false;
  112. return;
  113. }
  114. }
  115. }
  116. } );
  117. if ( isMaterialsValid === false ) {
  118. alert( strings.getKey( 'prompt/rendering/realistic/unsupportedMaterial' ) );
  119. return;
  120. }
  121. }
  122. //
  123. const json = editor.toJSON();
  124. const project = json.project;
  125. //
  126. const loader = new THREE.ObjectLoader();
  127. const camera = await loader.parseAsync( json.camera );
  128. camera.aspect = imageWidth.getValue() / imageHeight.getValue();
  129. camera.updateProjectionMatrix();
  130. camera.updateMatrixWorld();
  131. const scene = await loader.parseAsync( json.scene );
  132. const renderer = new THREE.WebGLRenderer( { antialias: true, logarithmicDepthBuffer: true } );
  133. renderer.setSize( imageWidth.getValue(), imageHeight.getValue() );
  134. if ( project.shadows !== undefined ) renderer.shadowMap.enabled = project.shadows;
  135. if ( project.shadowType !== undefined ) renderer.shadowMap.type = project.shadowType;
  136. if ( project.toneMapping !== undefined ) renderer.toneMapping = project.toneMapping;
  137. if ( project.toneMappingExposure !== undefined ) renderer.toneMappingExposure = project.toneMappingExposure;
  138. // popup
  139. const width = imageWidth.getValue() / window.devicePixelRatio;
  140. const height = imageHeight.getValue() / window.devicePixelRatio;
  141. const left = ( screen.width - width ) / 2;
  142. const top = ( screen.height - height ) / 2;
  143. const output = window.open( '', '_blank', `location=no,left=${left},top=${top},width=${width},height=${height}` );
  144. const meta = document.createElement( 'meta' );
  145. meta.name = 'viewport';
  146. meta.content = 'width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0';
  147. output.document.head.appendChild( meta );
  148. output.document.body.style.background = '#000';
  149. output.document.body.style.margin = '0px';
  150. output.document.body.style.overflow = 'hidden';
  151. const canvas = renderer.domElement;
  152. canvas.style.width = width + 'px';
  153. canvas.style.height = height + 'px';
  154. output.document.body.appendChild( canvas );
  155. //
  156. switch ( shadingTypeSelect.getValue() ) {
  157. case 'solid':
  158. renderer.render( scene, camera );
  159. renderer.dispose();
  160. break;
  161. case 'realistic':
  162. const status = document.createElement( 'div' );
  163. status.style.position = 'absolute';
  164. status.style.top = '10px';
  165. status.style.left = '10px';
  166. status.style.color = 'white';
  167. status.style.fontFamily = 'system-ui';
  168. status.style.fontSize = '12px';
  169. output.document.body.appendChild( status );
  170. const pathTracer = new ViewportPathtracer( renderer );
  171. pathTracer.init( scene, camera );
  172. pathTracer.setSize( imageWidth.getValue(), imageHeight.getValue() );
  173. const maxSamples = Math.max( pathTracerMinSamples, Math.min( pathTracerMaxSamples, samplesNumber.getValue() ) );
  174. function animate() {
  175. if ( output.closed === true ) return;
  176. const samples = Math.floor( pathTracer.getSamples() ) + 1;
  177. if ( samples < maxSamples ) {
  178. requestAnimationFrame( animate );
  179. }
  180. pathTracer.update();
  181. const progress = Math.floor( samples / maxSamples * 100 );
  182. status.textContent = `${ samples } / ${ maxSamples } ( ${ progress }% )`;
  183. if ( progress === 100 ) {
  184. status.textContent += ' \u2713';
  185. }
  186. }
  187. animate();
  188. break;
  189. }
  190. this.close();
  191. } );
  192. buttonsRow.appendChild( renderButton.dom );
  193. const cancelButton = new UIButton( strings.getKey( 'menubar/render/cancel' ) );
  194. cancelButton.setWidth( '80px' );
  195. cancelButton.setMarginLeft( '8px' );
  196. cancelButton.onClick( () => this.close() );
  197. buttonsRow.appendChild( cancelButton.dom );
  198. }
  199. close() {
  200. this.dom.remove();
  201. }
  202. }
  203. class RenderVideoDialog {
  204. constructor( editor, strings ) {
  205. const dom = document.createElement( 'div' );
  206. dom.className = 'Dialog';
  207. this.dom = dom;
  208. const background = document.createElement( 'div' );
  209. background.className = 'Dialog-background';
  210. background.addEventListener( 'click', () => this.close() );
  211. dom.appendChild( background );
  212. const content = document.createElement( 'div' );
  213. content.className = 'Dialog-content';
  214. dom.appendChild( content );
  215. // Title
  216. const titleBar = document.createElement( 'div' );
  217. titleBar.className = 'Dialog-title';
  218. titleBar.textContent = strings.getKey( 'menubar/render' ) + ' ' + strings.getKey( 'menubar/render/video' );
  219. content.appendChild( titleBar );
  220. // Body
  221. const body = document.createElement( 'div' );
  222. body.className = 'Dialog-body';
  223. content.appendChild( body );
  224. // Resolution
  225. function toDiv2() {
  226. this.setValue( 2 * Math.floor( this.getValue() / 2 ) );
  227. }
  228. const resolutionRow = new UIRow();
  229. body.appendChild( resolutionRow.dom );
  230. resolutionRow.add( new UIText( strings.getKey( 'sidebar/project/resolution' ) ).setClass( 'Label' ) );
  231. const videoWidth = new UIInteger( 1024 ).setTextAlign( 'center' ).setWidth( '28px' ).setStep( 2 ).onChange( toDiv2 );
  232. resolutionRow.add( videoWidth );
  233. resolutionRow.add( new UIText( '\u00D7' ).setTextAlign( 'center' ).setFontSize( '12px' ).setWidth( '12px' ) );
  234. const videoHeight = new UIInteger( 1024 ).setTextAlign( 'center' ).setWidth( '28px' ).setStep( 2 ).onChange( toDiv2 );
  235. resolutionRow.add( videoHeight );
  236. const videoFPS = new UIInteger( 30 ).setTextAlign( 'center' ).setWidth( '20px' );
  237. resolutionRow.add( videoFPS );
  238. resolutionRow.add( new UIText( 'fps' ).setFontSize( '12px' ) );
  239. // Duration
  240. const videoDurationRow = new UIRow();
  241. videoDurationRow.add( new UIText( strings.getKey( 'sidebar/project/duration' ) ).setClass( 'Label' ) );
  242. body.appendChild( videoDurationRow.dom );
  243. const videoDuration = new UIInteger( 10 );
  244. videoDurationRow.add( videoDuration );
  245. // Quality
  246. const qualityRow = new UIRow();
  247. qualityRow.add( new UIText( strings.getKey( 'menubar/render/quality' ) ).setClass( 'Label' ) );
  248. body.appendChild( qualityRow.dom );
  249. const videoQuality = new UISelect().setOptions( {
  250. 'low': 'Low',
  251. 'medium': 'Medium',
  252. 'high': 'High',
  253. 'ultra': 'Ultra'
  254. } ).setWidth( '170px' ).setValue( 'high' );
  255. qualityRow.add( videoQuality );
  256. // Buttons
  257. const buttonsRow = document.createElement( 'div' );
  258. buttonsRow.className = 'Dialog-buttons';
  259. body.appendChild( buttonsRow );
  260. const renderButton = new UIButton( strings.getKey( 'sidebar/project/render' ) );
  261. renderButton.setWidth( '80px' );
  262. renderButton.onClick( async () => {
  263. const player = new APP.Player();
  264. player.load( editor.toJSON() );
  265. player.setPixelRatio( 1 );
  266. player.setSize( videoWidth.getValue(), videoHeight.getValue() );
  267. //
  268. const width = videoWidth.getValue() / window.devicePixelRatio;
  269. const height = videoHeight.getValue() / window.devicePixelRatio;
  270. const canvas = player.canvas;
  271. canvas.style.width = width + 'px';
  272. canvas.style.height = height + 'px';
  273. const left = ( screen.width - width ) / 2;
  274. const top = ( screen.height - height ) / 2;
  275. const output = window.open( '', '_blank', `location=no,left=${left},top=${top},width=${width},height=${height}` );
  276. const meta = document.createElement( 'meta' );
  277. meta.name = 'viewport';
  278. meta.content = 'width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0';
  279. output.document.head.appendChild( meta );
  280. output.document.body.style.background = '#000';
  281. output.document.body.style.margin = '0px';
  282. output.document.body.style.overflow = 'hidden';
  283. output.document.body.appendChild( canvas );
  284. const status = document.createElement( 'div' );
  285. status.style.position = 'absolute';
  286. status.style.top = '10px';
  287. status.style.left = '10px';
  288. status.style.color = 'white';
  289. status.style.fontFamily = 'system-ui';
  290. status.style.fontSize = '12px';
  291. status.style.textShadow = '0 0 2px black';
  292. output.document.body.appendChild( status );
  293. const video = document.createElement( 'video' );
  294. video.width = width;
  295. video.height = height;
  296. video.controls = true;
  297. video.loop = true;
  298. video.hidden = true;
  299. output.document.body.appendChild( video );
  300. output.addEventListener( 'unload', function () {
  301. if ( video.src.startsWith( 'blob:' ) ) {
  302. URL.revokeObjectURL( video.src );
  303. }
  304. } );
  305. //
  306. const fps = videoFPS.getValue();
  307. const duration = videoDuration.getValue();
  308. const frames = duration * fps;
  309. const encodedChunks = [];
  310. let codecConfig = null;
  311. const videoEncoder = new VideoEncoder( {
  312. output: ( chunk, metadata ) => {
  313. if ( metadata?.decoderConfig?.description ) {
  314. codecConfig = new Uint8Array( metadata.decoderConfig.description );
  315. }
  316. const chunkData = new Uint8Array( chunk.byteLength );
  317. chunk.copyTo( chunkData );
  318. encodedChunks.push( { data: chunkData, timestamp: chunk.timestamp, type: chunk.type } );
  319. },
  320. error: ( e ) => console.error( 'VideoEncoder error:', e )
  321. } );
  322. const qualityToBitrate = {
  323. 'low': 2e6,
  324. 'medium': 5e6,
  325. 'high': 10e6,
  326. 'ultra': 20e6
  327. };
  328. videoEncoder.configure( {
  329. codec: 'avc1.640028',
  330. width: videoWidth.getValue(),
  331. height: videoHeight.getValue(),
  332. bitrate: qualityToBitrate[ videoQuality.getValue() ],
  333. framerate: fps,
  334. avc: { format: 'avc' }
  335. } );
  336. let currentTime = 0;
  337. let aborted = false;
  338. for ( let i = 0; i < frames; i ++ ) {
  339. if ( output.closed ) {
  340. aborted = true;
  341. break;
  342. }
  343. player.render( currentTime );
  344. const bitmap = await createImageBitmap( canvas );
  345. const frame = new VideoFrame( bitmap, { timestamp: i * ( 1_000_000 / fps ) } );
  346. videoEncoder.encode( frame, { keyFrame: i % fps === 0 } );
  347. frame.close();
  348. bitmap.close();
  349. currentTime += 1 / fps;
  350. const progress = Math.floor( ( i + 1 ) / frames * 100 );
  351. status.textContent = `${ i + 1 } / ${ frames } ( ${ progress }% )`;
  352. }
  353. if ( ! aborted ) {
  354. await videoEncoder.flush();
  355. videoEncoder.close();
  356. output.document.body.removeChild( canvas );
  357. const mp4Data = createMP4( encodedChunks, codecConfig, videoWidth.getValue(), videoHeight.getValue(), fps );
  358. status.textContent = `${ frames } / ${ frames } ( 100% ) ${ formatFileSize( mp4Data.byteLength ) } \u2713`;
  359. video.src = URL.createObjectURL( new Blob( [ mp4Data ], { type: 'video/mp4' } ) );
  360. video.hidden = false;
  361. }
  362. player.dispose();
  363. this.close();
  364. } );
  365. buttonsRow.appendChild( renderButton.dom );
  366. const cancelButton = new UIButton( strings.getKey( 'menubar/render/cancel' ) );
  367. cancelButton.setWidth( '80px' );
  368. cancelButton.setMarginLeft( '8px' );
  369. cancelButton.onClick( () => this.close() );
  370. buttonsRow.appendChild( cancelButton.dom );
  371. }
  372. close() {
  373. this.dom.remove();
  374. }
  375. }
  376. // Simple MP4 muxer for H.264 encoded chunks
  377. function createMP4( chunks, avcC, width, height, fps ) {
  378. const timescale = 90000;
  379. const frameDuration = timescale / fps;
  380. function u32( value ) {
  381. return new Uint8Array( [ ( value >> 24 ) & 0xFF, ( value >> 16 ) & 0xFF, ( value >> 8 ) & 0xFF, value & 0xFF ] );
  382. }
  383. function u16( value ) {
  384. return new Uint8Array( [ ( value >> 8 ) & 0xFF, value & 0xFF ] );
  385. }
  386. function str( s ) {
  387. return new TextEncoder().encode( s );
  388. }
  389. function concat( ...arrays ) {
  390. const totalLength = arrays.reduce( ( sum, arr ) => sum + arr.length, 0 );
  391. const result = new Uint8Array( totalLength );
  392. let offset = 0;
  393. for ( const arr of arrays ) {
  394. result.set( arr, offset );
  395. offset += arr.length;
  396. }
  397. return result;
  398. }
  399. function box( type, ...contents ) {
  400. const data = concat( ...contents );
  401. const size = data.length + 8;
  402. return concat( u32( size ), str( type ), data );
  403. }
  404. function fullBox( type, version, flags, ...contents ) {
  405. return box( type, new Uint8Array( [ version, ( flags >> 16 ) & 0xFF, ( flags >> 8 ) & 0xFF, flags & 0xFF ] ), ...contents );
  406. }
  407. // ftyp
  408. const ftyp = box( 'ftyp',
  409. str( 'isom' ),
  410. u32( 512 ),
  411. str( 'isom' ), str( 'iso2' ), str( 'avc1' ), str( 'mp41' )
  412. );
  413. // Collect sample info
  414. const sampleSizes = [];
  415. const syncSamples = [];
  416. for ( let i = 0; i < chunks.length; i ++ ) {
  417. sampleSizes.push( chunks[ i ].data.length );
  418. if ( chunks[ i ].type === 'key' ) syncSamples.push( i + 1 );
  419. }
  420. // mdat
  421. let mdatSize = 8;
  422. for ( const chunk of chunks ) mdatSize += chunk.data.length;
  423. // stsd - Sample Description
  424. const avc1 = box( 'avc1',
  425. new Uint8Array( 6 ), // reserved
  426. u16( 1 ), // data reference index
  427. new Uint8Array( 16 ), // pre-defined + reserved
  428. u16( width ),
  429. u16( height ),
  430. u32( 0x00480000 ), // horizontal resolution 72 dpi
  431. u32( 0x00480000 ), // vertical resolution 72 dpi
  432. u32( 0 ), // reserved
  433. u16( 1 ), // frame count
  434. new Uint8Array( 32 ), // compressor name
  435. u16( 0x0018 ), // depth
  436. new Uint8Array( [ 0xFF, 0xFF ] ), // pre-defined
  437. box( 'avcC', avcC )
  438. );
  439. const stsd = fullBox( 'stsd', 0, 0, u32( 1 ), avc1 );
  440. // stts - Time-to-Sample
  441. const stts = fullBox( 'stts', 0, 0,
  442. u32( 1 ),
  443. u32( chunks.length ),
  444. u32( frameDuration )
  445. );
  446. // stsc - Sample-to-Chunk
  447. const stsc = fullBox( 'stsc', 0, 0,
  448. u32( 1 ),
  449. u32( 1 ), u32( chunks.length ), u32( 1 )
  450. );
  451. // stsz - Sample Sizes
  452. const stszData = [ u32( 0 ), u32( chunks.length ) ];
  453. for ( const size of sampleSizes ) stszData.push( u32( size ) );
  454. const stsz = fullBox( 'stsz', 0, 0, ...stszData );
  455. // stco - Chunk Offsets (placeholder, will be updated)
  456. const stco = fullBox( 'stco', 0, 0, u32( 1 ), u32( 0 ) );
  457. // stss - Sync Samples
  458. const stssData = [ u32( syncSamples.length ) ];
  459. for ( const sync of syncSamples ) stssData.push( u32( sync ) );
  460. const stss = fullBox( 'stss', 0, 0, ...stssData );
  461. // stbl
  462. const stbl = box( 'stbl', stsd, stts, stsc, stsz, stco, stss );
  463. // dinf
  464. const dref = fullBox( 'dref', 0, 0,
  465. u32( 1 ),
  466. fullBox( 'url ', 0, 1 )
  467. );
  468. const dinf = box( 'dinf', dref );
  469. // vmhd
  470. const vmhd = fullBox( 'vmhd', 0, 1, new Uint8Array( 8 ) );
  471. // minf
  472. const minf = box( 'minf', vmhd, dinf, stbl );
  473. // hdlr
  474. const hdlr = fullBox( 'hdlr', 0, 0,
  475. u32( 0 ), // pre-defined
  476. str( 'vide' ),
  477. new Uint8Array( 12 ), // reserved
  478. str( 'VideoHandler' ), new Uint8Array( 1 )
  479. );
  480. // mdhd
  481. const durationInTimescale = chunks.length * frameDuration;
  482. const mdhd = fullBox( 'mdhd', 0, 0,
  483. u32( 0 ), // creation time
  484. u32( 0 ), // modification time
  485. u32( timescale ),
  486. u32( durationInTimescale ),
  487. u16( 0x55C4 ), // language (und)
  488. u16( 0 ) // quality
  489. );
  490. // mdia
  491. const mdia = box( 'mdia', mdhd, hdlr, minf );
  492. // tkhd
  493. const tkhd = fullBox( 'tkhd', 0, 3,
  494. u32( 0 ), // creation time
  495. u32( 0 ), // modification time
  496. u32( 1 ), // track id
  497. u32( 0 ), // reserved
  498. u32( durationInTimescale ),
  499. new Uint8Array( 8 ), // reserved
  500. u16( 0 ), // layer
  501. u16( 0 ), // alternate group
  502. u16( 0 ), // volume
  503. u16( 0 ), // reserved
  504. // matrix
  505. u32( 0x00010000 ), u32( 0 ), u32( 0 ),
  506. u32( 0 ), u32( 0x00010000 ), u32( 0 ),
  507. u32( 0 ), u32( 0 ), u32( 0x40000000 ),
  508. u32( width << 16 ), // width (16.16 fixed point)
  509. u32( height << 16 ) // height (16.16 fixed point)
  510. );
  511. // trak
  512. const trak = box( 'trak', tkhd, mdia );
  513. // mvhd
  514. const mvhd = fullBox( 'mvhd', 0, 0,
  515. u32( 0 ), // creation time
  516. u32( 0 ), // modification time
  517. u32( timescale ),
  518. u32( durationInTimescale ),
  519. u32( 0x00010000 ), // rate (1.0)
  520. u16( 0x0100 ), // volume (1.0)
  521. new Uint8Array( 10 ), // reserved
  522. // matrix
  523. u32( 0x00010000 ), u32( 0 ), u32( 0 ),
  524. u32( 0 ), u32( 0x00010000 ), u32( 0 ),
  525. u32( 0 ), u32( 0 ), u32( 0x40000000 ),
  526. new Uint8Array( 24 ), // pre-defined
  527. u32( 2 ) // next track id
  528. );
  529. // moov
  530. const moov = box( 'moov', mvhd, trak );
  531. // Calculate actual mdat offset and update stco
  532. const mdatOffset = ftyp.length + moov.length;
  533. const moovArray = new Uint8Array( moov );
  534. // Find and update stco offset (search for 'stco' in moov)
  535. for ( let i = 0; i < moovArray.length - 16; i ++ ) {
  536. if ( moovArray[ i ] === 0x73 && moovArray[ i + 1 ] === 0x74 &&
  537. moovArray[ i + 2 ] === 0x63 && moovArray[ i + 3 ] === 0x6F ) {
  538. // Found 'stco', offset value is at i + 12
  539. const offset = mdatOffset + 8;
  540. moovArray[ i + 12 ] = ( offset >> 24 ) & 0xFF;
  541. moovArray[ i + 13 ] = ( offset >> 16 ) & 0xFF;
  542. moovArray[ i + 14 ] = ( offset >> 8 ) & 0xFF;
  543. moovArray[ i + 15 ] = offset & 0xFF;
  544. break;
  545. }
  546. }
  547. // Update mdat size
  548. const mdatSizeBytes = u32( mdatSize );
  549. // Combine all parts
  550. const result = new Uint8Array( ftyp.length + moovArray.length + mdatSize );
  551. let offset = 0;
  552. result.set( ftyp, offset ); offset += ftyp.length;
  553. result.set( moovArray, offset ); offset += moovArray.length;
  554. result.set( mdatSizeBytes, offset );
  555. result.set( str( 'mdat' ), offset + 4 );
  556. offset += 8;
  557. for ( const chunk of chunks ) {
  558. result.set( chunk.data, offset );
  559. offset += chunk.data.length;
  560. }
  561. return result;
  562. }
  563. function formatFileSize( sizeB, K = 1024 ) {
  564. if ( sizeB === 0 ) return '0B';
  565. const sizes = [ sizeB, sizeB / K, sizeB / K / K ].reverse();
  566. const units = [ 'B', 'KB', 'MB' ].reverse();
  567. const index = sizes.findIndex( size => size >= 1 );
  568. return new Intl.NumberFormat( 'en-us', { useGrouping: true, maximumFractionDigits: 1 } )
  569. .format( sizes[ index ] ) + units[ index ];
  570. }
  571. export { MenubarRender };
粤ICP备19079148号