Add metadata to mp4 files that usually would not need postprocessing

Extract MP4 metadata (udta) generation into separate helper class
This commit is contained in:
tobigr 2026-02-08 12:15:54 +01:00
parent 4899651b81
commit 45fae37610
5 changed files with 733 additions and 198 deletions

View File

@ -1093,6 +1093,8 @@ public class DownloadDialog extends DialogFragment
if (secondary.getSizeInBytes() > 0 && videoSize > 0) {
nearLength = secondary.getSizeInBytes() + videoSize;
}
} else if (selectedStream.getFormat() == MediaFormat.MPEG_4) {
psName = Postprocessing.ALGORITHM_MP4_METADATA;
}
break;
case R.id.subtitle_button:

View File

@ -11,23 +11,22 @@ import org.schabi.newpipe.streams.Mp4DashReader.Mp4Track;
import org.schabi.newpipe.streams.Mp4DashReader.TrackKind;
import org.schabi.newpipe.streams.Mp4DashReader.TrunEntry;
import org.schabi.newpipe.streams.io.SharpStream;
import org.schabi.newpipe.util.StreamInfoMetadataHelper;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import us.shandian.giga.postprocessing.Mp4MetadataHelper;
/**
* MP4 muxer that builds a standard MP4 file from DASH fragmented MP4 sources.
*
* <p>
* See <a href="https://atomicparsley.sourceforge.net/mpeg-4files.html">
* https://atomicparsley.sourceforge.net/mpeg-4files.html</a> for information on
* @see <a href="https://atomicparsley.sourceforge.net/mpeg-4files.html">
* https://atomicparsley.sourceforge.net/mpeg-4files.html</a> for a quick summary on
* the MP4 file format and its specification.
* </p>
*
* @see <a href="https://developer.apple.com/documentation/quicktime-file-format/">
* Apple Quick Time Format Specification</a> which is the basis for MP4 file format
* and contains detailed information about the structure of MP4 files.
* @author kapodamy
*/
public class Mp4FromDashWriter {
@ -64,8 +63,8 @@ public class Mp4FromDashWriter {
private final ArrayList<Integer> compatibleBrands = new ArrayList<>(5);
private final StreamInfo streamInfo;
private final Bitmap thumbnail;
private final Mp4MetadataHelper metadataHelper;
public Mp4FromDashWriter(final StreamInfo streamInfo,
final Bitmap thumbnail,
@ -76,8 +75,26 @@ public class Mp4FromDashWriter {
}
}
this.streamInfo = streamInfo;
this.thumbnail = thumbnail;
this.metadataHelper = new Mp4MetadataHelper(
this::auxOffset,
buffer -> {
try {
auxWrite(buffer);
} catch (final IOException e) {
throw new RuntimeException(e);
}
},
offset -> {
try {
return lengthFor(offset);
} catch (final IOException e) {
throw new RuntimeException(e);
}
},
streamInfo,
thumbnail
);
sourceTracks = sources;
readers = new Mp4DashReader[sourceTracks.length];
readersChunks = new Mp4DashChunk[readers.length];
@ -733,7 +750,7 @@ public class Mp4FromDashWriter {
makeMvhd(longestTrack);
makeUdta();
metadataHelper.makeUdta();
for (int i = 0; i < tracks.length; i++) {
if (tracks[i].trak.tkhd.matrix.length != 36) {
@ -929,191 +946,6 @@ public class Mp4FromDashWriter {
}
/**
* Create the 'udta' box with metadata fields.
* {@code udta} is a user data box that can contain various types of metadata,
* including title, artist, date, and cover art.
* @see <a href="https://developer.apple.com/documentation/quicktime-file-format/
* user_data_atoms">Apple Quick Time Format Specification for user data atoms</a>
* @see <a href="https://wiki.multimedia.cx/index.php?title=FFmpeg_Metadata
* #QuickTime/MOV/MP4/M4A/et_al.">Multimedia Wiki FFmpeg Metadata</a>
* @see <a href="https://atomicparsley.sourceforge.net/mpeg-4files.html">atomicparsley docs</a>
* for a short and understandable reference about metadata keys and values
* @throws IOException
*/
private void makeUdta() throws IOException {
if (streamInfo == null) {
return;
}
// udta
final int startUdta = auxOffset();
auxWrite(ByteBuffer.allocate(8).putInt(0).putInt(0x75647461).array()); // "udta"
// meta (full box: type + version/flags)
final int startMeta = auxOffset();
auxWrite(ByteBuffer.allocate(8).putInt(0).putInt(0x6D657461).array()); // "meta"
auxWrite(ByteBuffer.allocate(4).putInt(0).array()); // version & flags = 0
// hdlr inside meta
auxWrite(makeMetaHdlr());
// ilst container
final int startIlst = auxOffset();
auxWrite(ByteBuffer.allocate(8).putInt(0).putInt(0x696C7374).array()); // "ilst"
// write metadata items
final var metaHelper = new StreamInfoMetadataHelper(streamInfo);
final String title = metaHelper.getTitle();
final String artist = metaHelper.getArtist();
final String date = metaHelper.getReleaseDate().getLocalDateTime()
.toLocalDate().toString();
final String recordLabel = metaHelper.getRecordLabel();
final String copyright = metaHelper.getCopyright();
if (title != null && !title.isEmpty()) {
writeMetaItem("©nam", title);
}
if (artist != null && !artist.isEmpty()) {
writeMetaItem("©ART", artist);
}
if (date != null && !date.isEmpty()) {
// this means 'year' in mp4 metadata, who the hell thought that?
writeMetaItem("©day", date);
}
if (recordLabel != null && !recordLabel.isEmpty()) {
writeMetaItem("©lab", recordLabel);
}
if (copyright != null && !copyright.isEmpty()) {
writeMetaItem("©cpy", copyright);
}
if (thumbnail != null) {
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
thumbnail.compress(Bitmap.CompressFormat.PNG, 100, baos);
final byte[] imgBytes = baos.toByteArray();
baos.close();
// 0x0000000E = PNG type indicator for 'data' box (0x0D = JPEG)
writeMetaCover(imgBytes, 0x0000000E);
}
// fix lengths
lengthFor(startIlst);
lengthFor(startMeta);
lengthFor(startUdta);
}
/**
* Helper to write a metadata item inside the 'ilst' box.
*
* <pre>
* [size][key] [data_box]
* data_box = [size]["data"][type(4bytes)=1][locale(4bytes)=0][payload]
* </pre>
*
* @param keyStr 4-char metadata key
* @param value the metadata value
* @throws IOException
*/
//
private void writeMetaItem(final String keyStr, final String value) throws IOException {
final byte[] valBytes = value.getBytes(StandardCharsets.UTF_8);
final byte[] keyBytes = keyStr.getBytes(StandardCharsets.ISO_8859_1);
final int dataBoxSize = 16 + valBytes.length; // 4(size)+4("data")+4(type/locale)+payload
final int itemBoxSize = 8 + dataBoxSize; // 4(size)+4(key)+dataBox
final ByteBuffer buf = ByteBuffer.allocate(itemBoxSize);
buf.putInt(itemBoxSize);
// key (4 bytes)
if (keyBytes.length == 4) {
buf.put(keyBytes);
} else {
// fallback: pad or truncate
final byte[] kb = new byte[4];
System.arraycopy(keyBytes, 0, kb, 0, Math.min(keyBytes.length, 4));
buf.put(kb);
}
// data box
buf.putInt(dataBoxSize);
buf.putInt(0x64617461); // "data"
buf.putInt(0x00000001); // well-known type indicator (UTF-8)
buf.putInt(0x00000000); // locale
buf.put(valBytes);
auxWrite(buf.array());
}
/**
* Create a minimal hdlr box for the meta container.
* The boxsize is fixed (33 bytes) as no name is provided.
* @return byte array with the hdlr box
*/
private byte[] makeMetaHdlr() {
final ByteBuffer buf = ByteBuffer.allocate(33);
buf.putInt(33);
buf.putInt(0x68646C72); // "hdlr"
buf.putInt(0x00000000); // pre-defined
buf.putInt(0x6D646972); // "mdir" handler_type (metadata directory)
buf.putInt(0x00000000); // subtype / reserved
buf.put(new byte[12]); // reserved
buf.put((byte) 0x00); // name (empty, null-terminated)
return buf.array();
}
/**
* Helper to add cover image inside the 'udta' box.
* <p>
* This method writes the 'covr' metadata item which contains the cover image.
* The cover image is displayed as thumbnail in many media players and file managers.
* </p>
* <pre>
* [size][key] [data_box]
* data_box = [size]["data"][type(4bytes)][locale(4bytes)=0][payload]
* </pre>
*
* @param imageData image byte data
* @param dataType type indicator: 0x0000000E = PNG, 0x0000000D = JPEG
* @throws IOException
*/
private void writeMetaCover(final byte[] imageData, final int dataType) throws IOException {
if (imageData == null || imageData.length == 0) {
return;
}
final byte[] keyBytes = "covr".getBytes(StandardCharsets.ISO_8859_1);
// data box: 4(size) + 4("data") + 4(type) + 4(locale) + payload
final int dataBoxSize = 16 + imageData.length;
final int itemBoxSize = 8 + dataBoxSize;
final ByteBuffer buf = ByteBuffer.allocate(itemBoxSize);
buf.putInt(itemBoxSize);
// key (4 chars)
if (keyBytes.length == 4) {
buf.put(keyBytes);
} else {
final byte[] kb = new byte[4];
System.arraycopy(keyBytes, 0, kb, 0, Math.min(keyBytes.length, 4));
buf.put(kb);
}
// data box
buf.putInt(dataBoxSize);
buf.putInt(0x64617461); // "data"
buf.putInt(dataType); // type indicator: 0x0000000E = PNG, 0x0000000D = JPEG
buf.putInt(0x00000000); // locale
buf.put(imageData);
auxWrite(buf.array());
}
static class TablesInfo {
int stts;
int stsc;

View File

@ -0,0 +1,473 @@
package us.shandian.giga.postprocessing;
import org.schabi.newpipe.streams.io.SharpStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Arrays;
/**
* Postprocessing algorithm to insert metadata into an existing MP4 file
* by modifying/adding the 'udta' box inside 'moov'.
*
* @see <a href="https://atomicparsley.sourceforge.net/mpeg-4files.html">
* https://atomicparsley.sourceforge.net/mpeg-4files.html</a> for a quick summary on
* the MP4 file format and its specification.
* @see <a href="https://developer.apple.com/documentation/quicktime-file-format/">
* Apple Quick Time Format Specification</a> which is the basis for MP4 file format
* and contains detailed information about the structure of MP4 files.
* @see <a href="https://developer.apple.com/documentation/quicktime-file-format/
* * user_data_atoms">Apple Quick Time Format Specification for user data atoms (udta)</a>
*/
public class Mp4Metadata extends Postprocessing {
Mp4Metadata() {
super(false, true, ALGORITHM_MP4_METADATA);
}
@Override
boolean test(SharpStream... sources) throws IOException {
// quick check: ensure there's at least one source and it looks like an MP4,
// i.e. the file has a 'moov' box near the beginning.
// THe 'udta' box is inserted inside 'moov', so if there's no 'moov' we can't do anything.
if (sources == null || sources.length == 0 || sources[0] == null) return false;
final SharpStream src = sources[0];
try {
src.rewind();
// scan first few boxes until we find moov or reach a reasonable limit
final int MAX_SCAN = 8 * 1024 * 1024; // 8 MiB
int scanned = 0;
while (scanned < MAX_SCAN) {
// read header
byte[] header = new byte[8];
int r = readFully(src, header, 0, 8);
if (r < 8) break;
final int boxSize = ByteBuffer.wrap(header, 0, 4).getInt();
final int boxType = ByteBuffer.wrap(header, 4, 4).getInt();
if (boxType == 0x6D6F6F76) { // "moov"
return true;
}
long skip = (boxSize > 8) ? (boxSize - 8) : 0;
// boxSize == 0 means extends to EOF -> stop scanning
if (boxSize == 0) break;
// attempt skip
long skipped = src.skip(skip);
if (skipped < skip) break;
scanned += 8 + (int) skip;
}
return false;
} finally {
// best-effort rewind; ignore problems here
try {
src.rewind();
} catch (IOException ignored) {
// nothing to do
}
}
}
@Override
int process(SharpStream out, SharpStream... sources) throws IOException {
if (sources == null || sources.length == 0) return OK_RESULT;
final SharpStream src = sources[0];
src.rewind();
// helper buffer for copy
final byte[] buf = new byte[64 * 1024];
// copy until moov
while (true) {
// read header
byte[] header = new byte[8];
int h = readFully(src, header, 0, 8);
if (h < 8) {
// no more data, nothing to do
return OK_RESULT;
}
final int boxSize = ByteBuffer.wrap(header, 0, 4).getInt();
final int boxType = ByteBuffer.wrap(header, 4, 4).getInt();
if (boxType != 0x6D6F6F76) { // not "moov" -> copy whole box
// write header
out.write(header);
long remaining = (boxSize > 8) ? (boxSize - 8) : 0;
if (boxSize == 0) {
// box extends to EOF: copy rest and return
int r;
while ((r = src.read(buf)) > 0) {
out.write(buf, 0, r);
}
return OK_RESULT;
}
while (remaining > 0) {
int read = src.read(buf, 0, (int) Math.min(buf.length, remaining));
if (read <= 0) break;
out.write(buf, 0, read);
remaining -= read;
}
continue;
}
// found moov. read full moov box into memory
long moovSize = boxSize;
boolean hasLargeSize = false;
if (moovSize == 1) {
// extended size: read 8 bytes
byte[] ext = new byte[8];
readFully(src, ext, 0, 8);
moovSize = ByteBuffer.wrap(ext).getLong();
hasLargeSize = true;
}
if (moovSize < 8) {
// malformed
return OK_RESULT;
}
final int toRead = (int) (moovSize - (hasLargeSize ? 16 : 8));
final byte[] moovPayload = new byte[toRead];
readFully(src, moovPayload, 0, toRead);
// search for udta inside moov
int udtaIndex = indexOfBox(moovPayload, 0x75647461); // "udta"
if (udtaIndex < 0) {
// no udta: build udta using helper and insert before first 'trak' atom
byte[] udtaBytes = buildUdta();
int insertPos = indexOfBox(moovPayload, 0x7472616B); // "trak"
if (insertPos < 0) insertPos = moovPayload.length;
byte[] newPayload = new byte[moovPayload.length + udtaBytes.length];
System.arraycopy(moovPayload, 0, newPayload, 0, insertPos);
System.arraycopy(udtaBytes, 0, newPayload, insertPos, udtaBytes.length);
System.arraycopy(moovPayload, insertPos, newPayload, insertPos + udtaBytes.length,
moovPayload.length - insertPos);
long newMoovSize = moovSize + udtaBytes.length;
long delta = newMoovSize - moovSize;
// adjust chunk offsets in the new payload so stco/co64 entries point to correct mdat offsets
adjustChunkOffsetsRecursive(newPayload, 0, newPayload.length, delta);
// write updated moov header
if (hasLargeSize) {
out.write(intToBytes(1));
out.write(intToBytes(0x6D6F6F76)); // "moov"
out.write(longToBytes(newMoovSize));
} else {
out.write(intToBytes((int) newMoovSize));
out.write(intToBytes(0x6D6F6F76)); // "moov"
}
out.write(newPayload);
} else {
// udta exists: replace the existing udta box with newly built udta
// determine old udta size (support extended size and size==0 -> till end of moov)
if (udtaIndex + 8 > moovPayload.length) {
// malformed; just write original and continue
if (hasLargeSize) {
out.write(intToBytes(1));
out.write(intToBytes(0x6D6F6F76)); // "moov"
out.write(longToBytes(moovSize));
} else {
out.write(intToBytes((int) moovSize));
out.write(intToBytes(0x6D6F6F76)); // "moov"
}
out.write(moovPayload);
} else {
int sizeField = readUInt32(moovPayload, udtaIndex);
long oldUdtaSize;
if (sizeField == 1) {
// extended
if (udtaIndex + 16 > moovPayload.length) {
oldUdtaSize = ((long) moovPayload.length) - udtaIndex; // fallback
} else {
oldUdtaSize = readUInt64(moovPayload, udtaIndex + 8);
}
} else if (sizeField == 0) {
// until end of file/moov
oldUdtaSize = ((long) moovPayload.length) - udtaIndex;
} else {
oldUdtaSize = sizeField & 0xFFFFFFFFL;
}
// compute the integer length (bounded by remaining payload)
int oldUdtaIntLen = (int) Math.min(oldUdtaSize, (moovPayload.length - udtaIndex));
// build new udta
byte[] newUdta = buildUdta();
// If new udta fits into old udta area, overwrite in place and keep moov size unchanged
if (newUdta.length <= oldUdtaIntLen) {
byte[] newPayload = new byte[moovPayload.length];
// copy prefix
System.arraycopy(moovPayload, 0, newPayload, 0, udtaIndex);
// copy new udta
System.arraycopy(newUdta, 0, newPayload, udtaIndex, newUdta.length);
// pad remaining old udta space with zeros
int padStart = udtaIndex + newUdta.length;
int padLen = oldUdtaIntLen - newUdta.length;
if (padLen > 0) {
Arrays.fill(newPayload, padStart, padStart + padLen, (byte) 0);
}
// copy suffix
int suffixStart = udtaIndex + oldUdtaIntLen;
System.arraycopy(moovPayload, suffixStart, newPayload, udtaIndex + oldUdtaIntLen,
moovPayload.length - suffixStart);
// moovSize unchanged
if (hasLargeSize) {
out.write(intToBytes(1));
out.write(intToBytes(0x6D6F6F76));
out.write(longToBytes(moovSize));
} else {
out.write(intToBytes((int) moovSize));
out.write(intToBytes(0x6D6F6F76));
}
out.write(newPayload);
} else {
// construct new moov payload by replacing the old udta region (previous behavior)
int newPayloadLen = moovPayload.length - oldUdtaIntLen + newUdta.length;
byte[] newPayload = new byte[newPayloadLen];
// copy prefix
System.arraycopy(moovPayload, 0, newPayload, 0, udtaIndex);
// copy new udta
System.arraycopy(newUdta, 0, newPayload, udtaIndex, newUdta.length);
// copy suffix
int suffixStart = udtaIndex + oldUdtaIntLen;
System.arraycopy(moovPayload, suffixStart, newPayload, udtaIndex + newUdta.length,
moovPayload.length - suffixStart);
long newMoovSize = moovSize - oldUdtaSize + newUdta.length;
long delta = newMoovSize - moovSize;
// adjust chunk offsets in the new payload so stco/co64 entries point to correct mdat offsets
adjustChunkOffsetsRecursive(newPayload, 0, newPayload.length, delta);
// write updated moov header
if (hasLargeSize) {
out.write(intToBytes(1));
out.write(intToBytes(0x6D6F6F76)); // "moov"
out.write(longToBytes(newMoovSize));
} else {
out.write(intToBytes((int) newMoovSize));
out.write(intToBytes(0x6D6F6F76)); // "moov"
}
out.write(newPayload);
}
}
}
// copy rest of file
int r;
while ((r = src.read(buf)) > 0) {
out.write(buf, 0, r);
}
return OK_RESULT;
}
}
private void adjustChunkOffsetsRecursive(byte[] payload, int start,
int length, long delta) throws IOException {
int idx = start;
final int end = start + length;
while (idx + 8 <= end) {
int boxSize = readUInt32(payload, idx);
int boxType = readUInt32(payload, idx + 4);
if (boxSize == 0) {
// box extends to end of parent
boxSize = end - idx;
} else if (boxSize < 0) {
break;
}
int headerLen = 8;
long declaredSize = ((long) boxSize) & 0xFFFFFFFFL;
if (boxSize == 1) {
// extended size
if (idx + 16 > end) break;
declaredSize = readUInt64(payload, idx + 8);
headerLen = 16;
}
int contentStart = idx + headerLen;
int contentLen = (int) (declaredSize - headerLen);
if (contentLen < 0 || contentStart + contentLen > end) {
// invalid, stop
break;
}
if (boxType == 0x7374636F) { // 'stco'
// version/flags(4) entry_count(4) entries
int entryCountOff = contentStart + 4;
if (entryCountOff + 4 > end) return;
int count = readUInt32(payload, entryCountOff);
int entriesStart = entryCountOff + 4;
for (int i = 0; i < count; i++) {
int entryOff = entriesStart + i * 4;
if (entryOff + 4 > end) break;
long val = ((long) readUInt32(payload, entryOff)) & 0xFFFFFFFFL;
long newVal = val + delta;
if (newVal < 0 || newVal > 0xFFFFFFFFL) {
throw new IOException("stco entry overflow after applying delta");
}
putUInt32(payload, entryOff, (int) newVal);
}
} else if (boxType == 0x636F3634) { // 'co64'
int entryCountOff = contentStart + 4;
if (entryCountOff + 4 > end) return;
int count = readUInt32(payload, entryCountOff);
int entriesStart = entryCountOff + 4;
for (int i = 0; i < count; i++) {
int entryOff = entriesStart + i * 8;
if (entryOff + 8 > end) break;
long val = readUInt64(payload, entryOff);
long newVal = val + delta;
putUInt64(payload, entryOff, newVal);
}
} else {
// recurse into container boxes
if (contentLen >= 8) {
adjustChunkOffsetsRecursive(payload, contentStart, contentLen, delta);
}
}
idx += (int) declaredSize;
}
}
private static int readUInt32(byte[] buf, int off) {
return ((buf[off] & 0xFF) << 24) | ((buf[off + 1] & 0xFF) << 16)
| ((buf[off + 2] & 0xFF) << 8) | (buf[off + 3] & 0xFF);
}
private static long readUInt64(byte[] buf, int off) {
return ((long) readUInt32(buf, off) << 32) | ((long) readUInt32(buf, off + 4) & 0xFFFFFFFFL);
}
private static void putUInt32(byte[] buf, int off, int v) {
buf[off] = (byte) ((v >>> 24) & 0xFF);
buf[off + 1] = (byte) ((v >>> 16) & 0xFF);
buf[off + 2] = (byte) ((v >>> 8) & 0xFF);
buf[off + 3] = (byte) (v & 0xFF);
}
private static void putUInt64(byte[] buf, int off, long v) {
putUInt32(buf, off, (int) ((v >>> 32) & 0xFFFFFFFFL));
putUInt32(buf, off + 4, (int) (v & 0xFFFFFFFFL));
}
private static int readFully(SharpStream in, byte[] buf, int off, int len) throws IOException {
int readTotal = 0;
while (readTotal < len) {
int r = in.read(buf, off + readTotal, len - readTotal);
if (r <= 0) break;
readTotal += r;
}
return readTotal;
}
private static int indexOfBox(byte[] payload, int boxType) {
int idx = 0;
while (idx + 8 <= payload.length) {
int size = readUInt32(payload, idx);
int type = readUInt32(payload, idx + 4);
if (type == boxType) return idx;
if (size <= 0) break;
idx += size;
}
return -1;
}
private static byte[] intToBytes(int v) {
return ByteBuffer.allocate(4).putInt(v).array();
}
private static byte[] longToBytes(long v) {
return ByteBuffer.allocate(8).putLong(v).array();
}
/**
* Build udta bytes using {@link Mp4MetadataHelper}.
*/
private byte[] buildUdta() throws IOException {
final GrowableByteArray aux = new GrowableByteArray(Math.max(64 * 1024, 256 * 1024));
final Mp4MetadataHelper helper = new Mp4MetadataHelper(
aux::position,
aux::put,
offset -> {
int size = aux.position() - offset;
aux.putInt(offset, size);
return size;
},
streamInfo,
thumbnail
);
helper.makeUdta();
return aux.toByteArray();
}
/**
* Small growable byte array helper with minimal random-access putInt support
*/
private static final class GrowableByteArray {
private byte[] buf;
private int pos = 0;
GrowableByteArray(int initial) {
buf = new byte[initial];
}
int position() { return pos; }
void put(byte[] data) {
ensureCapacity(pos + data.length);
System.arraycopy(data, 0, buf, pos, data.length);
pos += data.length;
}
void putInt(int offset, int value) {
ensureCapacity(offset + 4);
buf[offset] = (byte) ((value >>> 24) & 0xff);
buf[offset + 1] = (byte) ((value >>> 16) & 0xff);
buf[offset + 2] = (byte) ((value >>> 8) & 0xff);
buf[offset + 3] = (byte) (value & 0xff);
}
private void ensureCapacity(int min) {
if (min <= buf.length) return;
int newCap = buf.length * 2;
while (newCap < min) newCap *= 2;
buf = Arrays.copyOf(buf, newCap);
}
byte[] toByteArray() {
return Arrays.copyOf(buf, pos);
}
}
}

View File

@ -0,0 +1,224 @@
package us.shandian.giga.postprocessing;
import android.graphics.Bitmap;
import androidx.annotation.Nullable;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.util.StreamInfoMetadataHelper;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import javax.annotation.Nonnull;
public final class Mp4MetadataHelper {
@Nullable
final StreamInfo streamInfo;
@Nullable final Bitmap thumbnail;
@Nonnull final Supplier<Integer> auxOffset;
@Nonnull final Consumer<byte[]> auxWriteBytes;
@Nonnull final Function<Integer, Integer> lengthFor;
public Mp4MetadataHelper(@Nonnull Supplier<Integer> auxOffset,
@Nonnull Consumer<byte[]> auxWriteBytes,
@Nonnull Function<Integer, Integer> lengthFor,
@Nullable final StreamInfo streamInfo,
@Nullable final Bitmap thumbnail) {
this.auxOffset = auxOffset;
this.auxWriteBytes = auxWriteBytes;
this.lengthFor = lengthFor;
this.streamInfo = streamInfo;
this.thumbnail = thumbnail;
}
/**
* Create the 'udta' box with metadata fields.
* {@code udta} is a user data box that can contain various types of metadata,
* including title, artist, date, and cover art.
* @see <a href="https://developer.apple.com/documentation/quicktime-file-format/
* user_data_atoms">Apple Quick Time Format Specification for user data atoms</a>
* @see <a href="https://wiki.multimedia.cx/index.php?title=FFmpeg_Metadata
* #QuickTime/MOV/MP4/M4A/et_al.">Multimedia Wiki FFmpeg Metadata</a>
* @see <a href="https://atomicparsley.sourceforge.net/mpeg-4files.html">atomicparsley docs</a>
* for a short and understandable reference about metadata keys and values
* @throws IOException
*/
public void makeUdta() throws IOException {
if (streamInfo == null) {
return;
}
// udta
final int startUdta = auxOffset.get();
auxWriteBytes.accept(ByteBuffer.allocate(8).putInt(0).putInt(0x75647461).array()); // "udta"
// meta (full box: type + version/flags)
final int startMeta = auxOffset.get();
auxWriteBytes.accept(ByteBuffer.allocate(8).putInt(0).putInt(0x6D657461).array()); // "meta"
auxWriteBytes.accept(ByteBuffer.allocate(4).putInt(0).array()); // version & flags = 0
// hdlr inside meta
auxWriteBytes.accept(makeMetaHdlr());
// ilst container
final int startIlst = auxOffset.get();
auxWriteBytes.accept(ByteBuffer.allocate(8).putInt(0).putInt(0x696C7374).array()); // "ilst"
// write metadata items
final var metaHelper = new StreamInfoMetadataHelper(streamInfo);
final String title = metaHelper.getTitle();
final String artist = metaHelper.getArtist();
final String date = metaHelper.getReleaseDate().getLocalDateTime()
.toLocalDate().toString();
final String recordLabel = metaHelper.getRecordLabel();
final String copyright = metaHelper.getCopyright();
if (title != null && !title.isEmpty()) {
writeMetaItem("©nam", title);
}
if (artist != null && !artist.isEmpty()) {
writeMetaItem("©ART", artist);
}
if (date != null && !date.isEmpty()) {
// this means 'year' in mp4 metadata, who the hell thought that?
writeMetaItem("©day", date);
}
if (recordLabel != null && !recordLabel.isEmpty()) {
writeMetaItem("©lab", recordLabel);
}
if (copyright != null && !copyright.isEmpty()) {
writeMetaItem("©cpy", copyright);
}
if (thumbnail != null) {
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
thumbnail.compress(Bitmap.CompressFormat.PNG, 100, baos);
final byte[] imgBytes = baos.toByteArray();
baos.close();
// 0x0000000E = PNG type indicator for 'data' box (0x0D = JPEG)
writeMetaCover(imgBytes, 0x0000000E);
}
// fix lengths
lengthFor.apply(startIlst);
lengthFor.apply(startMeta);
lengthFor.apply(startUdta);
}
/**
* Helper to write a metadata item inside the 'ilst' box.
*
* <pre>
* [size][key] [data_box]
* data_box = [size]["data"][type(4bytes)=1][locale(4bytes)=0][payload]
* </pre>
*
* @param keyStr 4-char metadata key
* @param value the metadata value
* @throws IOException
*/
private void writeMetaItem(final String keyStr, final String value) throws IOException {
final byte[] valBytes = value.getBytes(StandardCharsets.UTF_8);
final byte[] keyBytes = keyStr.getBytes(StandardCharsets.ISO_8859_1);
final int dataBoxSize = 16 + valBytes.length; // 4(size)+4("data")+4(type/locale)+payload
final int itemBoxSize = 8 + dataBoxSize; // 4(size)+4(key)+dataBox
final ByteBuffer buf = ByteBuffer.allocate(itemBoxSize);
buf.putInt(itemBoxSize);
// key (4 bytes)
if (keyBytes.length == 4) {
buf.put(keyBytes);
} else {
// fallback: pad or truncate
final byte[] kb = new byte[4];
System.arraycopy(keyBytes, 0, kb, 0, Math.min(keyBytes.length, 4));
buf.put(kb);
}
// data box
buf.putInt(dataBoxSize);
buf.putInt(0x64617461); // "data"
buf.putInt(0x00000001); // well-known type indicator (UTF-8)
buf.putInt(0x00000000); // locale
buf.put(valBytes);
auxWriteBytes.accept(buf.array());
}
/**
* Create a minimal hdlr box for the meta container.
* The boxsize is fixed (33 bytes) as no name is provided.
* @return byte array with the hdlr box
*/
private byte[] makeMetaHdlr() {
final ByteBuffer buf = ByteBuffer.allocate(33);
buf.putInt(33);
buf.putInt(0x68646C72); // "hdlr"
buf.putInt(0x00000000); // pre-defined
buf.putInt(0x6D646972); // "mdir" handler_type (metadata directory)
buf.putInt(0x00000000); // subtype / reserved
buf.put(new byte[12]); // reserved
buf.put((byte) 0x00); // name (empty, null-terminated)
return buf.array();
}
/**
* Helper to add cover image inside the 'udta' box.
* <p>
* This method writes the 'covr' metadata item which contains the cover image.
* The cover image is displayed as thumbnail in many media players and file managers.
* </p>
* <pre>
* [size][key] [data_box]
* data_box = [size]["data"][type(4bytes)][locale(4bytes)=0][payload]
* </pre>
*
* @param imageData image byte data
* @param dataType type indicator: 0x0000000E = PNG, 0x0000000D = JPEG
* @throws IOException
*/
private void writeMetaCover(final byte[] imageData, final int dataType) throws IOException {
if (imageData == null || imageData.length == 0) {
return;
}
final byte[] keyBytes = "covr".getBytes(StandardCharsets.ISO_8859_1);
// data box: 4(size) + 4("data") + 4(type) + 4(locale) + payload
final int dataBoxSize = 16 + imageData.length;
final int itemBoxSize = 8 + dataBoxSize;
final ByteBuffer buf = ByteBuffer.allocate(itemBoxSize);
buf.putInt(itemBoxSize);
// key (4 chars)
if (keyBytes.length == 4) {
buf.put(keyBytes);
} else {
final byte[] kb = new byte[4];
System.arraycopy(keyBytes, 0, kb, 0, Math.min(keyBytes.length, 4));
buf.put(kb);
}
// data box
buf.putInt(dataBoxSize);
buf.putInt(0x64617461); // "data"
buf.putInt(dataType); // type indicator: 0x0000000E = PNG, 0x0000000D = JPEG
buf.putInt(0x00000000); // locale
buf.put(imageData);
auxWriteBytes.accept(buf.array());
}
}

View File

@ -29,6 +29,7 @@ public abstract class Postprocessing implements Serializable {
public static final String ALGORITHM_TTML_CONVERTER = "ttml";
public static final String ALGORITHM_WEBM_MUXER = "webm";
public static final String ALGORITHM_MP4_METADATA = "mp4-metadata";
public static final String ALGORITHM_MP4_FROM_DASH_MUXER = "mp4D-mp4";
public static final String ALGORITHM_M4A_NO_DASH = "mp4D-m4a";
public static final String ALGORITHM_OGG_FROM_WEBM_DEMUXER = "webm-ogg-d";
@ -44,6 +45,9 @@ public abstract class Postprocessing implements Serializable {
case ALGORITHM_WEBM_MUXER:
instance = new WebMMuxer();
break;
case ALGORITHM_MP4_METADATA:
instance = new Mp4Metadata();
break;
case ALGORITHM_MP4_FROM_DASH_MUXER:
instance = new Mp4FromDashMuxer();
break;