Menubar.Render.js 22 KB

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